mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(agent): align pico foundation with v2 contract
This commit is contained in:
parent
99a3948c4c
commit
51f3090790
12 changed files with 2179 additions and 690 deletions
|
|
@ -6,6 +6,10 @@ export type Scope =
|
|||
| { readonly type: "task"; readonly taskId: Id }
|
||||
| { readonly type: "shared"; readonly id: Id };
|
||||
|
||||
export type PublicScope = Exclude<Scope, { type: "shared" }>;
|
||||
export type StickyScope = Extract<PublicScope, { type: "session" | "task" }>;
|
||||
export type ConversationScope = Extract<PublicScope, { type: "conversation" }>;
|
||||
|
||||
declare const addressType: unique symbol;
|
||||
|
||||
export interface Address<T extends JsonValue = JsonValue> {
|
||||
|
|
@ -19,6 +23,7 @@ export interface Address<T extends JsonValue = JsonValue> {
|
|||
|
||||
export interface Value<T extends JsonValue> extends Address<T> {
|
||||
readonly kind: "value";
|
||||
readonly default?: T;
|
||||
}
|
||||
|
||||
export interface List<T extends JsonValue> extends Address<T> {
|
||||
|
|
@ -30,48 +35,239 @@ export interface Element<T extends JsonValue> {
|
|||
readonly value: T;
|
||||
}
|
||||
|
||||
type StickyOptions = { readonly key?: string; readonly rewind?: false };
|
||||
type ConversationOptions = { readonly key?: string; readonly rewind: boolean };
|
||||
type StickyScope = Extract<Scope, { type: "session" | "task" | "shared" }>;
|
||||
type ConversationScope = Extract<Scope, { type: "conversation" }>;
|
||||
type Options = StickyOptions | ConversationOptions;
|
||||
export interface UnboundValue<T extends JsonValue, R extends boolean = boolean> {
|
||||
readonly namespace: string;
|
||||
readonly key?: string;
|
||||
readonly rewind: R;
|
||||
readonly default?: T;
|
||||
readonly [addressType]?: T;
|
||||
bind(conversationId: Id): Value<T>;
|
||||
}
|
||||
|
||||
export function defineValue<T extends JsonValue>(
|
||||
scope: StickyScope,
|
||||
namespace: string,
|
||||
options?: StickyOptions,
|
||||
): Value<T>;
|
||||
export function defineValue<T extends JsonValue>(
|
||||
scope: ConversationScope,
|
||||
namespace: string,
|
||||
options: ConversationOptions,
|
||||
): Value<T>;
|
||||
export function defineValue<T extends JsonValue>(scope: Scope, namespace: string, options?: Options): Value<T> {
|
||||
export interface UnboundList<T extends JsonValue, R extends boolean = boolean> {
|
||||
readonly namespace: string;
|
||||
readonly key?: string;
|
||||
readonly rewind: R;
|
||||
readonly [addressType]?: T;
|
||||
bind(conversationId: Id): List<T>;
|
||||
}
|
||||
|
||||
export type ConversationValueDefinition<T extends JsonValue, R extends boolean = boolean> = UnboundValue<T, R>;
|
||||
export type ConversationListDefinition<T extends JsonValue, R extends boolean = boolean> = UnboundList<T, R>;
|
||||
export type PayloadOf<D> = D extends Address<infer BoundPayload>
|
||||
? BoundPayload
|
||||
: D extends UnboundValue<infer DefinitionPayload, boolean>
|
||||
? DefinitionPayload
|
||||
: never;
|
||||
export type ResolvedValue<D> = D extends { readonly default: infer V } ? V : PayloadOf<D> | undefined;
|
||||
|
||||
type DefaultedUnboundValue<T extends JsonValue, R extends boolean> = Omit<UnboundValue<T, R>, "bind" | "default"> & {
|
||||
readonly default: T;
|
||||
bind(conversationId: Id): Value<T> & { readonly default: T };
|
||||
};
|
||||
|
||||
type StickyOptions<T extends JsonValue> = { readonly key?: string; readonly default?: T };
|
||||
type DefaultedStickyOptions<T extends JsonValue> = { readonly key?: string; readonly default: T };
|
||||
type ConversationOptions<T extends JsonValue> = {
|
||||
readonly key?: string;
|
||||
readonly rewind: boolean;
|
||||
readonly default?: T;
|
||||
};
|
||||
type DefaultedConversationOptions<T extends JsonValue> = {
|
||||
readonly key?: string;
|
||||
readonly rewind: boolean;
|
||||
readonly default: T;
|
||||
};
|
||||
type InternalOptions<T extends JsonValue> = StickyOptions<T> & { readonly rewind?: boolean };
|
||||
|
||||
function assertPublicNamespace(namespace: string): void {
|
||||
if (namespace.startsWith("pi.")) throw new Error(`Namespace ${namespace} is reserved`);
|
||||
}
|
||||
|
||||
function assertPublicScope(scope: Scope): asserts scope is PublicScope {
|
||||
if (scope.type === "shared") throw new Error("Shared scope is internal");
|
||||
}
|
||||
|
||||
function value<T extends JsonValue>(scope: Scope, namespace: string, options?: InternalOptions<T>): Value<T> {
|
||||
return Object.freeze({
|
||||
...options,
|
||||
scope,
|
||||
namespace,
|
||||
kind: "value",
|
||||
kind: "value" as const,
|
||||
rewind: options?.rewind ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
function list<T extends JsonValue>(scope: Scope, namespace: string, options?: InternalOptions<T>): List<T> {
|
||||
return Object.freeze({
|
||||
...options,
|
||||
scope,
|
||||
namespace,
|
||||
kind: "list" as const,
|
||||
rewind: options?.rewind ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
export function defineValue<T extends JsonValue>(
|
||||
scope: StickyScope,
|
||||
namespace: string,
|
||||
options: DefaultedStickyOptions<T>,
|
||||
): Value<T> & { readonly default: T };
|
||||
export function defineValue<T extends JsonValue>(
|
||||
scope: StickyScope,
|
||||
namespace: string,
|
||||
options?: { readonly key?: string },
|
||||
): Value<T>;
|
||||
export function defineValue<T extends JsonValue>(
|
||||
scope: ConversationScope,
|
||||
namespace: string,
|
||||
options: DefaultedConversationOptions<T>,
|
||||
): Value<T> & { readonly default: T };
|
||||
export function defineValue<T extends JsonValue>(
|
||||
scope: ConversationScope,
|
||||
namespace: string,
|
||||
options: { readonly key?: string; readonly rewind: boolean },
|
||||
): Value<T>;
|
||||
export function defineValue<T extends JsonValue>(
|
||||
scope: Scope,
|
||||
namespace: string,
|
||||
options?: InternalOptions<T>,
|
||||
): Value<T> {
|
||||
assertPublicScope(scope);
|
||||
assertPublicNamespace(namespace);
|
||||
return value(scope, namespace, options);
|
||||
}
|
||||
|
||||
export function defineList<T extends JsonValue>(
|
||||
scope: StickyScope,
|
||||
namespace: string,
|
||||
options?: StickyOptions,
|
||||
options?: { readonly key?: string },
|
||||
): List<T>;
|
||||
export function defineList<T extends JsonValue>(
|
||||
scope: ConversationScope,
|
||||
namespace: string,
|
||||
options: ConversationOptions,
|
||||
options: Omit<ConversationOptions<T>, "default">,
|
||||
): List<T>;
|
||||
export function defineList<T extends JsonValue>(scope: Scope, namespace: string, options?: Options): List<T> {
|
||||
export function defineList<T extends JsonValue>(
|
||||
scope: Scope,
|
||||
namespace: string,
|
||||
options?: InternalOptions<T>,
|
||||
): List<T> {
|
||||
assertPublicScope(scope);
|
||||
assertPublicNamespace(namespace);
|
||||
return list(scope, namespace, options);
|
||||
}
|
||||
|
||||
export function conversationValue<T extends JsonValue>(
|
||||
namespace: string,
|
||||
options: { readonly key?: string; readonly rewind: true; readonly default: T },
|
||||
): DefaultedUnboundValue<T, true>;
|
||||
export function conversationValue<T extends JsonValue>(
|
||||
namespace: string,
|
||||
options: { readonly key?: string; readonly rewind: false; readonly default: T },
|
||||
): DefaultedUnboundValue<T, false>;
|
||||
export function conversationValue<T extends JsonValue>(
|
||||
namespace: string,
|
||||
options: { readonly key?: string; readonly rewind: true },
|
||||
): UnboundValue<T, true>;
|
||||
export function conversationValue<T extends JsonValue>(
|
||||
namespace: string,
|
||||
options: { readonly key?: string; readonly rewind: false },
|
||||
): UnboundValue<T, false>;
|
||||
export function conversationValue<T extends JsonValue>(
|
||||
namespace: string,
|
||||
options: ConversationOptions<T>,
|
||||
): UnboundValue<T> {
|
||||
assertPublicNamespace(namespace);
|
||||
return unboundValue(namespace, options);
|
||||
}
|
||||
|
||||
export function conversationList<T extends JsonValue>(
|
||||
namespace: string,
|
||||
options: { readonly key?: string; readonly rewind: true },
|
||||
): UnboundList<T, true>;
|
||||
export function conversationList<T extends JsonValue>(
|
||||
namespace: string,
|
||||
options: { readonly key?: string; readonly rewind: false },
|
||||
): UnboundList<T, false>;
|
||||
export function conversationList<T extends JsonValue>(
|
||||
namespace: string,
|
||||
options: Omit<ConversationOptions<T>, "default">,
|
||||
): UnboundList<T> {
|
||||
assertPublicNamespace(namespace);
|
||||
return unboundList(namespace, options);
|
||||
}
|
||||
|
||||
export function sessionValue<T extends JsonValue>(
|
||||
namespace: string,
|
||||
options: { readonly key?: string; readonly default: T },
|
||||
): Value<T> & { readonly default: T };
|
||||
export function sessionValue<T extends JsonValue>(namespace: string, options?: { readonly key?: string }): Value<T>;
|
||||
export function sessionValue<T extends JsonValue>(namespace: string, options?: StickyOptions<T>): Value<T> {
|
||||
return defineValue({ type: "session" }, namespace, options);
|
||||
}
|
||||
|
||||
/** Internal constructor for fixed `pi.*` definitions and shared task output state. */
|
||||
export function defineInternalValue<T extends JsonValue>(
|
||||
scope: Scope,
|
||||
namespace: string,
|
||||
options?: InternalOptions<T>,
|
||||
): Value<T> {
|
||||
return value(scope, namespace, options);
|
||||
}
|
||||
|
||||
/** Internal constructor for fixed `pi.*` definitions and shared task output state. */
|
||||
export function defineInternalList<T extends JsonValue>(
|
||||
scope: Scope,
|
||||
namespace: string,
|
||||
options?: InternalOptions<T>,
|
||||
): List<T> {
|
||||
return list(scope, namespace, options);
|
||||
}
|
||||
|
||||
/** Internal constructor for fixed conversation configuration definitions. */
|
||||
export function internalConversationValue<T extends JsonValue, R extends boolean>(
|
||||
namespace: string,
|
||||
options: { readonly key?: string; readonly rewind: R; readonly default: T },
|
||||
): DefaultedUnboundValue<T, R>;
|
||||
export function internalConversationValue<T extends JsonValue, R extends boolean>(
|
||||
namespace: string,
|
||||
options: { readonly key?: string; readonly rewind: R },
|
||||
): UnboundValue<T, R>;
|
||||
export function internalConversationValue<T extends JsonValue, R extends boolean>(
|
||||
namespace: string,
|
||||
options: { readonly key?: string; readonly rewind: R; readonly default?: T },
|
||||
): UnboundValue<T, R> {
|
||||
return unboundValue(namespace, options);
|
||||
}
|
||||
|
||||
function unboundValue<T extends JsonValue, R extends boolean>(
|
||||
namespace: string,
|
||||
options: { readonly key?: string; readonly rewind: R; readonly default?: T },
|
||||
): UnboundValue<T, R> {
|
||||
return Object.freeze({
|
||||
...options,
|
||||
scope,
|
||||
namespace,
|
||||
kind: "list",
|
||||
rewind: options?.rewind ?? false,
|
||||
bind: (conversationId: Id) =>
|
||||
value<T>({ type: "conversation", conversationId }, namespace, {
|
||||
key: options.key,
|
||||
rewind: options.rewind,
|
||||
default: options.default,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function unboundList<T extends JsonValue, R extends boolean>(
|
||||
namespace: string,
|
||||
options: { readonly key?: string; readonly rewind: R },
|
||||
): UnboundList<T, R> {
|
||||
return Object.freeze({
|
||||
...options,
|
||||
namespace,
|
||||
bind: (conversationId: Id) =>
|
||||
list<T>({ type: "conversation", conversationId }, namespace, {
|
||||
key: options.key,
|
||||
rewind: options.rewind,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import type { JsonRepresentation } from "@earendil-works/chord";
|
||||
|
||||
export type JsonPrimitive = null | boolean | number | string;
|
||||
export type JsonValue = JsonPrimitive | JsonObject | readonly JsonValue[];
|
||||
export type Stored<T> = JsonRepresentation<T>;
|
||||
|
||||
export interface JsonObject {
|
||||
readonly [key: string]: JsonValue;
|
||||
|
|
|
|||
|
|
@ -1,17 +1,22 @@
|
|||
import type { AssistantMessage, Message, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai";
|
||||
import type { Id, JsonValue } from "./core.ts";
|
||||
import type { AssistantMessage, Message, ToolResultMessage, Usage, UserMessage } from "@earendil-works/pi-ai";
|
||||
import type { Id, JsonValue, Stored } from "./core.ts";
|
||||
|
||||
export type ContextEdit =
|
||||
| { readonly target: Id; readonly action: "omit" }
|
||||
| { readonly target: Id; readonly action: "replace"; readonly messages: readonly Message[] };
|
||||
| { readonly target: Id; readonly action: "replace"; readonly messages: readonly Stored<Message>[] };
|
||||
|
||||
export interface EntryIdentity {
|
||||
export interface Entry {
|
||||
readonly id: Id;
|
||||
readonly conversationId: Id;
|
||||
readonly kind: string;
|
||||
readonly byTaskId?: Id;
|
||||
readonly data?: JsonValue;
|
||||
readonly model?: readonly Stored<Message>[];
|
||||
readonly head?: Id;
|
||||
readonly edits?: readonly ContextEdit[];
|
||||
}
|
||||
|
||||
export type EntryIdentity = Pick<Entry, "id" | "conversationId" | "kind" | "byTaskId">;
|
||||
export type EntryBase = EntryIdentity;
|
||||
|
||||
export interface EntryData<D extends JsonValue = JsonValue> {
|
||||
|
|
@ -19,7 +24,7 @@ export interface EntryData<D extends JsonValue = JsonValue> {
|
|||
}
|
||||
|
||||
export interface ModelProjection<M extends Message = Message> {
|
||||
readonly model: readonly M[];
|
||||
readonly model: readonly Stored<M>[];
|
||||
}
|
||||
|
||||
export interface ContextHead {
|
||||
|
|
@ -30,10 +35,8 @@ export interface ContextEdits {
|
|||
readonly edits: readonly ContextEdit[];
|
||||
}
|
||||
|
||||
export type Entry = EntryBase & Partial<EntryData & ModelProjection & ContextHead & ContextEdits>;
|
||||
|
||||
export type EntryInput<E extends Entry> = Omit<E, keyof EntryIdentity | "head"> &
|
||||
(E extends ContextHead ? { readonly head: Id | "self" } : { readonly head?: never });
|
||||
(E extends { readonly head: Id } ? { readonly head: Id | "self" } : { readonly head?: never });
|
||||
|
||||
export interface EntryKind<E extends Entry = Entry> {
|
||||
readonly kind: string;
|
||||
|
|
@ -55,12 +58,49 @@ export interface Conversation {
|
|||
readonly owner?: Id;
|
||||
}
|
||||
|
||||
export type UserEntry = EntryBase & { readonly model: readonly [UserMessage] };
|
||||
export type AssistantEntry = EntryBase & { readonly model: readonly [AssistantMessage] };
|
||||
export type ToolResultEntry = EntryBase &
|
||||
EntryData<JsonValue> & { readonly model: readonly [ToolResultMessage<JsonValue>] };
|
||||
export type NoticeEntry = EntryBase & Partial<EntryData> & { readonly model: readonly [UserMessage] };
|
||||
export type SummaryEntry = EntryBase &
|
||||
EntryData<{ readonly through: Id }> & { readonly model: readonly [UserMessage] } & ContextHead;
|
||||
export type HandoffEntry = EntryBase & { readonly model: readonly [UserMessage] } & ContextHead;
|
||||
export type ResetEntry = EntryBase & ContextHead;
|
||||
export type AssistantEntryData = { readonly attempt: number };
|
||||
export type UsageEntryData = { readonly attempt: number; readonly usage?: Stored<Usage>; readonly error: string };
|
||||
export type ToolControl = {
|
||||
readonly terminate?: true;
|
||||
readonly handoff?: string;
|
||||
readonly addTools?: readonly string[];
|
||||
};
|
||||
export type ToolDiagnostic = {
|
||||
readonly severity: "info" | "warn" | "error";
|
||||
readonly message: string;
|
||||
readonly code?: string;
|
||||
};
|
||||
export type ToolUsage = { readonly [key: string]: number };
|
||||
export type ToolResultData = {
|
||||
readonly details?: JsonValue;
|
||||
readonly usage?: ToolUsage;
|
||||
readonly diagnostics?: readonly ToolDiagnostic[];
|
||||
readonly control?: ToolControl;
|
||||
readonly truncated?: { readonly bytes: number; readonly lines: number };
|
||||
};
|
||||
export type SectionRecord =
|
||||
| { readonly key: string; readonly action: "set"; readonly value: JsonValue; readonly rendered: string }
|
||||
| { readonly key: string; readonly action: "remove" };
|
||||
export type SystemEntryData = { readonly baseline?: true; readonly sections: readonly SectionRecord[] };
|
||||
|
||||
export type UserEntry = EntryBase & {
|
||||
readonly model: readonly [Stored<UserMessage>];
|
||||
readonly data?: { readonly continuation: true; readonly from: Id };
|
||||
};
|
||||
export type AssistantEntry = EntryBase & {
|
||||
readonly model: readonly [Stored<AssistantMessage>];
|
||||
readonly data?: AssistantEntryData;
|
||||
};
|
||||
export type ToolResultEntry = EntryBase & {
|
||||
readonly model: readonly [Stored<ToolResultMessage>];
|
||||
readonly data: ToolResultData;
|
||||
};
|
||||
export type NoticeEntry = EntryBase & { readonly model: readonly [Stored<UserMessage>]; readonly data?: JsonValue };
|
||||
export type UsageEntry = EntryBase & { readonly data: UsageEntryData };
|
||||
export type SummaryEntry = EntryBase & {
|
||||
readonly model: readonly [Stored<UserMessage>];
|
||||
readonly data: { readonly through: Id };
|
||||
readonly head: Id;
|
||||
};
|
||||
export type HandoffEntry = EntryBase & { readonly model: readonly [Stored<UserMessage>]; readonly head: Id };
|
||||
export type ResetEntry = EntryBase & { readonly head: Id };
|
||||
|
|
|
|||
62
packages/agent/src/harness/pico/hooks.ts
Normal file
62
packages/agent/src/harness/pico/hooks.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import type { Context } from "@earendil-works/chord";
|
||||
import type { Id } from "./core.ts";
|
||||
|
||||
declare const hookIn: unique symbol;
|
||||
declare const hookOut: unique symbol;
|
||||
|
||||
export type Fold = "collect" | "first" | "chain";
|
||||
|
||||
export interface HookPoint<In, Out, F extends Fold = Fold> {
|
||||
readonly fold: F;
|
||||
readonly onThrow: "skip" | "abort";
|
||||
readonly [hookIn]?: In;
|
||||
readonly [hookOut]?: Out;
|
||||
}
|
||||
|
||||
export function defineHookPoint<In, Out = void>(options: {
|
||||
readonly fold: "collect";
|
||||
readonly onThrow: "skip" | "abort";
|
||||
}): HookPoint<In, Out, "collect">;
|
||||
export function defineHookPoint<In, Out = void>(options: {
|
||||
readonly fold: "first";
|
||||
readonly onThrow: "skip" | "abort";
|
||||
}): HookPoint<In, Out, "first">;
|
||||
export function defineHookPoint<In extends object, Out extends Partial<In>>(options: {
|
||||
readonly fold: "chain";
|
||||
readonly onThrow: "skip" | "abort";
|
||||
}): HookPoint<In, Out, "chain">;
|
||||
export function defineHookPoint(options: {
|
||||
readonly fold: Fold;
|
||||
readonly onThrow: "skip" | "abort";
|
||||
}): HookPoint<unknown, unknown> {
|
||||
return Object.freeze(options);
|
||||
}
|
||||
|
||||
export type HookPoints = Record<string, HookPoint<unknown, unknown>>;
|
||||
export type EmptyHookPoints = Record<never, never>;
|
||||
export type HookIn<P> = P extends HookPoint<infer In, unknown> ? In : never;
|
||||
export type HookOut<P> = P extends HookPoint<unknown, infer Out> ? Out : never;
|
||||
|
||||
// biome-ignore lint/suspicious/noConfusingVoidType: void allows handlers with no return value.
|
||||
type HookHandlerResult<P> = HookOut<P> | void;
|
||||
export type HookHandler<P> = (
|
||||
input: HookIn<P>,
|
||||
info: HookInfo,
|
||||
ctx: Context,
|
||||
) => HookHandlerResult<P> | Promise<HookHandlerResult<P>>;
|
||||
|
||||
export interface HookInfo {
|
||||
readonly kind: string;
|
||||
readonly taskId: Id;
|
||||
readonly conversationId: Id;
|
||||
}
|
||||
|
||||
export interface HookRunner<H extends HookPoints> {
|
||||
run<P extends H[keyof H]>(point: P, input: HookIn<P>, ctx: Context): Promise<HookResult<P>>;
|
||||
}
|
||||
|
||||
export type HookResult<P> = P extends { fold: "collect" }
|
||||
? { readonly outputs: readonly HookOut<P>[]; readonly threw?: unknown }
|
||||
: P extends { fold: "first" }
|
||||
? { readonly output?: HookOut<P>; readonly threw?: unknown }
|
||||
: { readonly output: HookIn<P>; readonly threw?: unknown };
|
||||
|
|
@ -1,8 +1,154 @@
|
|||
export * from "./addresses.ts";
|
||||
export * from "./core.ts";
|
||||
export * from "./entries.ts";
|
||||
export * from "./memory-storage.ts";
|
||||
export * from "./runtime.ts";
|
||||
export * from "./session.ts";
|
||||
export * from "./storage.ts";
|
||||
export * from "./tasks.ts";
|
||||
export type {
|
||||
Address,
|
||||
ConversationListDefinition,
|
||||
ConversationScope,
|
||||
ConversationValueDefinition,
|
||||
Element,
|
||||
List,
|
||||
PayloadOf,
|
||||
PublicScope,
|
||||
ResolvedValue,
|
||||
Scope,
|
||||
StickyScope,
|
||||
UnboundList,
|
||||
UnboundValue,
|
||||
Value,
|
||||
} from "./addresses.ts";
|
||||
export {
|
||||
conversationList,
|
||||
conversationValue,
|
||||
defineList,
|
||||
defineValue,
|
||||
sessionValue,
|
||||
} from "./addresses.ts";
|
||||
export type { Id, JsonObject, JsonPrimitive, JsonValue, Seq, Stored } from "./core.ts";
|
||||
export type {
|
||||
AssistantEntry,
|
||||
AssistantEntryData,
|
||||
ContextEdit,
|
||||
ContextEdits,
|
||||
ContextHead,
|
||||
Conversation,
|
||||
Entry,
|
||||
EntryBase,
|
||||
EntryData,
|
||||
EntryIdentity,
|
||||
EntryInput,
|
||||
EntryKind,
|
||||
HandoffEntry,
|
||||
ModelProjection,
|
||||
NoticeEntry,
|
||||
ResetEntry,
|
||||
SectionRecord,
|
||||
SummaryEntry,
|
||||
SystemEntryData,
|
||||
ToolControl,
|
||||
ToolDiagnostic,
|
||||
ToolResultData,
|
||||
ToolResultEntry,
|
||||
ToolUsage,
|
||||
UsageEntry,
|
||||
UsageEntryData,
|
||||
UserEntry,
|
||||
} from "./entries.ts";
|
||||
export { defineEntry } from "./entries.ts";
|
||||
export type {
|
||||
Fold,
|
||||
HookHandler,
|
||||
HookIn,
|
||||
HookInfo,
|
||||
HookOut,
|
||||
HookPoint,
|
||||
HookPoints,
|
||||
HookResult,
|
||||
HookRunner,
|
||||
} from "./hooks.ts";
|
||||
export { defineHookPoint } from "./hooks.ts";
|
||||
export { MemoryStorage } from "./memory-storage.ts";
|
||||
export type {
|
||||
AbortClosure,
|
||||
AbortFinalTx,
|
||||
AbortTaskConversation,
|
||||
AbortTaskRuntime,
|
||||
AbortTx,
|
||||
Acceptance,
|
||||
BaseTaskTx,
|
||||
BoundPublicState,
|
||||
BoundTxState,
|
||||
ConversationCreateSpec,
|
||||
ConversationTx,
|
||||
FinalOutput,
|
||||
FinalTx,
|
||||
GenerationOutput,
|
||||
HostTx,
|
||||
InputHandle,
|
||||
InputOutcome,
|
||||
PublicList,
|
||||
PublicValue,
|
||||
QueuedInput,
|
||||
ReadonlyTaskOutput,
|
||||
RuntimeOutput,
|
||||
ScratchReader,
|
||||
SendInput,
|
||||
StickyPublicValue,
|
||||
StoredEntryDraft,
|
||||
StoredInputResult,
|
||||
TaskConversation,
|
||||
TaskOutput,
|
||||
TaskRuntime,
|
||||
TerminalClosure,
|
||||
TerminalInputOutcome,
|
||||
ToolInput,
|
||||
ToolOutputState,
|
||||
TxList,
|
||||
TxReaders,
|
||||
TxState,
|
||||
TxValue,
|
||||
UserInput,
|
||||
} from "./runtime.ts";
|
||||
export { Closed, Faulted, NestedLineOperation, ReadAfterWrite } from "./session.ts";
|
||||
export type {
|
||||
ConversationScan,
|
||||
EntryScan,
|
||||
NewTask,
|
||||
Page,
|
||||
PageQuery,
|
||||
StateWrite,
|
||||
Storage,
|
||||
TaskScan,
|
||||
Write,
|
||||
} from "./storage.ts";
|
||||
export { InvalidHistoryPosition, OutputRetired, ScratchRetired } from "./storage.ts";
|
||||
export type {
|
||||
AbortedOf,
|
||||
AnyDefinedKind,
|
||||
CheckpointOf,
|
||||
Completion,
|
||||
ConfigBundle,
|
||||
DistributiveOmit,
|
||||
ExactJsonInput,
|
||||
FailureOf,
|
||||
InputOf,
|
||||
NoCheckpoint,
|
||||
NoExtra,
|
||||
OrdinaryKind,
|
||||
OutputOf,
|
||||
OutputState,
|
||||
PayloadsOf,
|
||||
ResultOf,
|
||||
RunningTask,
|
||||
StoredTaskOutputRef,
|
||||
Task,
|
||||
TaskBase,
|
||||
TaskCheckpoint,
|
||||
TaskKind,
|
||||
TaskKindDefinition,
|
||||
TaskKindFactory,
|
||||
TaskOf,
|
||||
TaskOutcome,
|
||||
TaskOutputDefinition,
|
||||
TaskOutputField,
|
||||
TaskOutputKind,
|
||||
TaskOutputRef,
|
||||
} from "./tasks.ts";
|
||||
export { defineTask, defineTaskOutput } from "./tasks.ts";
|
||||
|
|
|
|||
|
|
@ -6,9 +6,12 @@ import {
|
|||
type ConversationScan,
|
||||
type EntryScan,
|
||||
InvalidHistoryPosition,
|
||||
OutputRetired,
|
||||
type Page,
|
||||
type PageQuery,
|
||||
ScratchRetired,
|
||||
type Storage,
|
||||
type StoredConversation,
|
||||
type TaskScan,
|
||||
type Write,
|
||||
} from "./storage.ts";
|
||||
|
|
@ -25,7 +28,12 @@ type ListHistoryWrite =
|
|||
|
||||
type StoredState =
|
||||
| { readonly kind: "value"; readonly rewind: false; value?: JsonValue }
|
||||
| { readonly kind: "list"; readonly rewind: false; readonly elements: Map<Id, Element<JsonValue>> }
|
||||
| {
|
||||
readonly kind: "list";
|
||||
readonly rewind: false;
|
||||
readonly elements: Map<Id, Element<JsonValue>>;
|
||||
readonly known: Set<Id>;
|
||||
}
|
||||
| { readonly kind: "value"; readonly rewind: true; readonly writes: ValueHistoryWrite[] }
|
||||
| { readonly kind: "list"; readonly rewind: true; readonly writes: ListHistoryWrite[] };
|
||||
|
||||
|
|
@ -53,6 +61,12 @@ interface StoredEntry {
|
|||
readonly entry: Entry;
|
||||
}
|
||||
|
||||
interface ProspectiveWrites {
|
||||
readonly conversations: Map<Id, StoredConversation>;
|
||||
readonly entries: Map<Id, StoredEntry>;
|
||||
readonly listAppends: { readonly seq: Seq; readonly address: List<JsonValue>; readonly elementId: Id }[];
|
||||
}
|
||||
|
||||
function rewindable(
|
||||
address: Address,
|
||||
): address is Address & { readonly scope: Extract<Address["scope"], { type: "conversation" }>; readonly rewind: true } {
|
||||
|
|
@ -75,7 +89,8 @@ function page<T>(
|
|||
): Page<T> {
|
||||
const items: T[] = [];
|
||||
let more = false;
|
||||
for (const value of values) {
|
||||
const ordered = [...values].sort((left, right) => (ascending ? id(left) - id(right) : id(right) - id(left)));
|
||||
for (const value of ordered) {
|
||||
const valueId = id(value);
|
||||
if (
|
||||
!matches(value) ||
|
||||
|
|
@ -94,8 +109,9 @@ function page<T>(
|
|||
|
||||
export class MemoryStorage implements Storage {
|
||||
private nextObjectId: Id = 1;
|
||||
private committedIdHighWater: Id = 0;
|
||||
private nextSeq: Seq = 1;
|
||||
private readonly conversations = new Map<Id, Conversation>();
|
||||
private readonly conversations = new Map<Id, StoredConversation>();
|
||||
private readonly entries = new Map<Id, StoredEntry>();
|
||||
private readonly entriesByConversation = new Map<Id, StoredEntry[]>();
|
||||
private readonly tasks = new Map<Id, Task>();
|
||||
|
|
@ -106,19 +122,24 @@ export class MemoryStorage implements Storage {
|
|||
private readonly taskState = new Map<Id, ScopeState>();
|
||||
private readonly sharedState = new Map<Id, ScopeState>();
|
||||
|
||||
static create(): MemoryStorage {
|
||||
return new MemoryStorage();
|
||||
}
|
||||
|
||||
nextId(): Id {
|
||||
return this.nextObjectId++;
|
||||
}
|
||||
|
||||
async commit(writes: readonly Write[], _ctx: Context): Promise<readonly Seq[]> {
|
||||
this.validateListRemovals(writes);
|
||||
const seqs = writes.map((_, index) => this.nextSeq + index);
|
||||
const affectedOutputs = new Set<Id>();
|
||||
let nextObjectId = this.nextObjectId;
|
||||
let batchMaxId = this.committedIdHighWater;
|
||||
for (const write of writes) {
|
||||
if (write.type === "conversation.create") nextObjectId = Math.max(nextObjectId, write.conversation.id + 1);
|
||||
else if (write.type === "entry.append") nextObjectId = Math.max(nextObjectId, write.entry.id + 1);
|
||||
else if (write.type === "task.create") nextObjectId = Math.max(nextObjectId, write.task.id + 1);
|
||||
else if (write.type === "list.append") nextObjectId = Math.max(nextObjectId, write.element.id + 1);
|
||||
if (write.type === "conversation.create") batchMaxId = Math.max(batchMaxId, write.conversation.id);
|
||||
else if (write.type === "entry.append") batchMaxId = Math.max(batchMaxId, write.entry.id);
|
||||
else if (write.type === "task.create") batchMaxId = Math.max(batchMaxId, write.task.id);
|
||||
else if (write.type === "list.append") batchMaxId = Math.max(batchMaxId, write.element.id);
|
||||
if (write.type !== "task.create" && write.type !== "task.set") continue;
|
||||
const currentOutput = this.tasks.get(write.task.id)?.output?.id;
|
||||
if (currentOutput !== undefined) affectedOutputs.add(currentOutput);
|
||||
|
|
@ -131,23 +152,28 @@ export class MemoryStorage implements Storage {
|
|||
for (const id of affectedOutputs) {
|
||||
if (!this.outputReferences.has(id)) this.sharedState.delete(id);
|
||||
}
|
||||
this.nextObjectId = nextObjectId;
|
||||
this.committedIdHighWater = batchMaxId;
|
||||
this.nextObjectId = Math.max(this.nextObjectId, batchMaxId + 1);
|
||||
this.nextSeq += writes.length;
|
||||
return seqs;
|
||||
}
|
||||
|
||||
async close(_ctx: Context): Promise<void> {
|
||||
this.nextObjectId = this.committedIdHighWater + 1;
|
||||
}
|
||||
|
||||
async getConversations(ids: readonly Id[], _ctx: Context): Promise<ReadonlyMap<Id, Conversation>> {
|
||||
const result = new Map<Id, Conversation>();
|
||||
for (const id of ids) {
|
||||
const conversation = this.conversations.get(id);
|
||||
if (conversation !== undefined) result.set(id, conversation);
|
||||
if (conversation !== undefined) result.set(id, this.publicConversation(conversation));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async scanConversations(query: ConversationScan, _ctx: Context): Promise<Page<Conversation>> {
|
||||
return page(
|
||||
this.conversations.values(),
|
||||
[...this.conversations.values()].map((conversation) => this.publicConversation(conversation)),
|
||||
query,
|
||||
(conversation) => conversation.id,
|
||||
(conversation) =>
|
||||
|
|
@ -167,18 +193,24 @@ export class MemoryStorage implements Storage {
|
|||
}
|
||||
|
||||
async scanEntries(query: EntryScan, _ctx: Context): Promise<Page<Entry>> {
|
||||
return page(
|
||||
this.visibleEntries(query.conversationId, query.through),
|
||||
query,
|
||||
(entry) => entry.id,
|
||||
(entry) => query.kind === undefined || entry.kind === query.kind,
|
||||
false,
|
||||
);
|
||||
const cursor = query.cursor === undefined ? undefined : this.historyPosition(query.conversationId, query.cursor);
|
||||
const items: Entry[] = [];
|
||||
let more = false;
|
||||
for (const stored of this.visibleEntries(query.conversationId, query.through)) {
|
||||
if (cursor !== undefined && stored.seq >= cursor) continue;
|
||||
if (query.kind !== undefined && stored.entry.kind !== query.kind) continue;
|
||||
if (items.length === query.limit) {
|
||||
more = true;
|
||||
break;
|
||||
}
|
||||
items.push(stored.entry);
|
||||
}
|
||||
return { items, ...(more && items.length > 0 ? { next: items.at(-1)!.id } : {}) };
|
||||
}
|
||||
|
||||
async newestHead(conversationId: Id, at: Id, _ctx: Context): Promise<Entry | undefined> {
|
||||
for (const entry of this.visibleEntries(conversationId, at)) {
|
||||
if (entry.head !== undefined) return entry;
|
||||
for (const stored of this.visibleEntries(conversationId, at)) {
|
||||
if (stored.entry.head !== undefined) return stored.entry;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -210,8 +242,10 @@ export class MemoryStorage implements Storage {
|
|||
async getValue<T extends JsonValue>(address: Value<T>, at: Id | undefined, _ctx: Context): Promise<T | undefined> {
|
||||
if (at !== undefined) {
|
||||
if (!rewindable(address)) throw new InvalidHistoryPosition(at);
|
||||
this.assertScopeReadable(address);
|
||||
return this.rewindValue(address, this.historyPosition(address.scope.conversationId, at));
|
||||
}
|
||||
this.assertScopeReadable(address);
|
||||
if (rewindable(address)) return this.rewindValue(address, undefined);
|
||||
const state = this.storedState(address);
|
||||
return state?.kind === "value" && !state.rewind ? (state.value as T | undefined) : undefined;
|
||||
|
|
@ -224,26 +258,31 @@ export class MemoryStorage implements Storage {
|
|||
): Promise<readonly Element<T>[]> {
|
||||
if (at !== undefined) {
|
||||
if (!rewindable(address)) throw new InvalidHistoryPosition(at);
|
||||
this.assertScopeReadable(address);
|
||||
return this.rewindListElements(address, this.historyPosition(address.scope.conversationId, at));
|
||||
}
|
||||
this.assertScopeReadable(address);
|
||||
if (rewindable(address)) return this.rewindListElements(address, undefined);
|
||||
const state = this.storedState(address);
|
||||
if (state?.kind !== "list" || state.rewind) return [];
|
||||
return [...(state.elements.values() as Iterable<Element<T>>)];
|
||||
return [...(state.elements.values() as Iterable<Element<T>>)].sort((left, right) => left.id - right.id);
|
||||
}
|
||||
|
||||
private historyPosition(conversationId: Id, at: Id): Seq {
|
||||
const stored = this.entries.get(at);
|
||||
private historyPosition(conversationId: Id, at: Id, prospective?: ProspectiveWrites): Seq {
|
||||
const stored = prospective?.entries.get(at) ?? this.entries.get(at);
|
||||
if (stored === undefined) throw new InvalidHistoryPosition(at);
|
||||
|
||||
let currentId = conversationId;
|
||||
let cap: Seq | undefined;
|
||||
while (true) {
|
||||
if (stored.entry.conversationId === currentId && (cap === undefined || stored.seq <= cap)) return stored.seq;
|
||||
const conversation = this.conversations.get(currentId);
|
||||
const conversation = prospective?.conversations.get(currentId) ?? this.conversations.get(currentId);
|
||||
if (conversation?.parent === undefined) throw new InvalidHistoryPosition(at);
|
||||
const parentAtSeq = this.entries.get(conversation.parent.at)?.seq;
|
||||
if (parentAtSeq === undefined) throw new InvalidHistoryPosition(at);
|
||||
const parentAtSeq = this.historyPosition(
|
||||
conversation.parent.conversationId,
|
||||
conversation.parent.at,
|
||||
prospective,
|
||||
);
|
||||
cap = Math.min(cap ?? parentAtSeq, parentAtSeq);
|
||||
currentId = conversation.parent.conversationId;
|
||||
}
|
||||
|
|
@ -294,24 +333,134 @@ export class MemoryStorage implements Storage {
|
|||
else elements.clear();
|
||||
}
|
||||
}
|
||||
return [...elements.values()];
|
||||
return [...elements.values()].sort((left, right) => left.id - right.id);
|
||||
}
|
||||
|
||||
private *visibleEntries(conversationId: Id, through: Id | undefined): Iterable<Entry> {
|
||||
private *visibleEntries(conversationId: Id, through: Id | undefined): Iterable<StoredEntry> {
|
||||
let conversation = this.conversations.get(conversationId);
|
||||
let cap = through;
|
||||
let cap = through === undefined ? undefined : this.historyPosition(conversationId, through);
|
||||
while (conversation !== undefined) {
|
||||
const entries = this.entriesByConversation.get(conversation.id) ?? [];
|
||||
for (let index = entries.length - 1; index >= 0; index--) {
|
||||
const entry = entries[index]!.entry;
|
||||
if (cap === undefined || entry.id <= cap) yield entry;
|
||||
const stored = entries[index]!;
|
||||
if (cap === undefined || stored.seq <= cap) yield stored;
|
||||
}
|
||||
if (conversation.parent === undefined) return;
|
||||
cap = Math.min(cap ?? conversation.parent.at, conversation.parent.at);
|
||||
const parentAtSeq = this.historyPosition(conversation.parent.conversationId, conversation.parent.at);
|
||||
cap = Math.min(cap ?? parentAtSeq, parentAtSeq);
|
||||
conversation = this.conversations.get(conversation.parent.conversationId);
|
||||
}
|
||||
}
|
||||
|
||||
private validateListRemovals(writes: readonly Write[]): void {
|
||||
const prospective: ProspectiveWrites = {
|
||||
conversations: new Map(),
|
||||
entries: new Map(),
|
||||
listAppends: [],
|
||||
};
|
||||
for (let index = 0; index < writes.length; index++) {
|
||||
const write = writes[index]!;
|
||||
const seq = this.nextSeq + index;
|
||||
if (write.type === "conversation.create") {
|
||||
prospective.conversations.set(write.conversation.id, write.conversation);
|
||||
} else if (write.type === "entry.append") {
|
||||
prospective.entries.set(write.entry.id, { seq, entry: write.entry });
|
||||
} else if (write.type === "list.append") {
|
||||
prospective.listAppends.push({ seq, address: write.address, elementId: write.element.id });
|
||||
} else if (
|
||||
write.type === "list.remove" &&
|
||||
!this.listKnowsElement(write.address, write.elementId, prospective)
|
||||
) {
|
||||
throw new Error(`List element ${write.elementId} does not belong to this list lineage`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private listKnowsElement(address: List<JsonValue>, elementId: Id, prospective?: ProspectiveWrites): boolean {
|
||||
if (!rewindable(address)) {
|
||||
const state = this.storedState(address);
|
||||
return (
|
||||
(state?.kind === "list" && !state.rewind && state.known.has(elementId)) ||
|
||||
(prospective?.listAppends.some(
|
||||
(append) => append.elementId === elementId && this.sameAddress(append.address, address),
|
||||
) ??
|
||||
false)
|
||||
);
|
||||
}
|
||||
let conversationId = address.scope.conversationId;
|
||||
let cap: Seq | undefined;
|
||||
while (true) {
|
||||
const state = this.conversationStoredState(conversationId, address);
|
||||
const appendedProspectively = prospective?.listAppends.some(
|
||||
(append) =>
|
||||
append.elementId === elementId &&
|
||||
append.address.scope.type === "conversation" &&
|
||||
append.address.scope.conversationId === conversationId &&
|
||||
append.address.rewind === address.rewind &&
|
||||
append.address.namespace === address.namespace &&
|
||||
append.address.key === address.key &&
|
||||
(cap === undefined || append.seq <= cap),
|
||||
);
|
||||
if (
|
||||
appendedProspectively ||
|
||||
(state?.kind === "list" &&
|
||||
state.rewind &&
|
||||
state.writes.some(
|
||||
(write) =>
|
||||
write.type === "append" &&
|
||||
write.element.id === elementId &&
|
||||
(cap === undefined || write.seq <= cap),
|
||||
))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const conversation = prospective?.conversations.get(conversationId) ?? this.conversations.get(conversationId);
|
||||
if (conversation?.parent === undefined) return false;
|
||||
const parentAtSeq = this.historyPosition(
|
||||
conversation.parent.conversationId,
|
||||
conversation.parent.at,
|
||||
prospective,
|
||||
);
|
||||
cap = Math.min(cap ?? parentAtSeq, parentAtSeq);
|
||||
conversationId = conversation.parent.conversationId;
|
||||
}
|
||||
}
|
||||
|
||||
private sameAddress(left: Address, right: Address): boolean {
|
||||
if (
|
||||
left.kind !== right.kind ||
|
||||
left.rewind !== right.rewind ||
|
||||
left.namespace !== right.namespace ||
|
||||
left.key !== right.key ||
|
||||
left.scope.type !== right.scope.type
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (left.scope.type === "session") return true;
|
||||
if (left.scope.type === "conversation" && right.scope.type === "conversation") {
|
||||
return left.scope.conversationId === right.scope.conversationId;
|
||||
}
|
||||
if (left.scope.type === "task" && right.scope.type === "task") return left.scope.taskId === right.scope.taskId;
|
||||
return left.scope.type === "shared" && right.scope.type === "shared" && left.scope.id === right.scope.id;
|
||||
}
|
||||
|
||||
private publicConversation(conversation: StoredConversation): Conversation {
|
||||
return {
|
||||
id: conversation.id,
|
||||
...(conversation.parent === undefined ? {} : { parent: conversation.parent }),
|
||||
...(conversation.owner === undefined ? {} : { owner: conversation.owner }),
|
||||
};
|
||||
}
|
||||
|
||||
private assertScopeReadable(address: Address): void {
|
||||
if (address.scope.type === "task") {
|
||||
const task = this.tasks.get(address.scope.taskId);
|
||||
if (task === undefined || task.status === "terminal") throw new ScratchRetired(address.scope.taskId);
|
||||
} else if (address.scope.type === "shared" && !this.outputReferences.has(address.scope.id)) {
|
||||
throw new OutputRetired(address.scope.id);
|
||||
}
|
||||
}
|
||||
|
||||
private apply(write: Write, seq: Seq): void {
|
||||
if (write.type === "conversation.create") {
|
||||
this.conversations.set(write.conversation.id, write.conversation);
|
||||
|
|
@ -345,8 +494,10 @@ export class MemoryStorage implements Storage {
|
|||
else if (write.type === "list.remove")
|
||||
state.writes.push({ seq, type: "remove", elementId: write.elementId });
|
||||
else state.writes.push({ seq, type: "clear" });
|
||||
} else if (write.type === "list.append") state.elements.set(write.element.id, write.element);
|
||||
else if (write.type === "list.remove") state.elements.delete(write.elementId);
|
||||
} else if (write.type === "list.append") {
|
||||
state.known.add(write.element.id);
|
||||
state.elements.set(write.element.id, write.element);
|
||||
} else if (write.type === "list.remove") state.elements.delete(write.elementId);
|
||||
else state.elements.clear();
|
||||
}
|
||||
}
|
||||
|
|
@ -382,7 +533,7 @@ export class MemoryStorage implements Storage {
|
|||
private listState(address: List<JsonValue>): Extract<StoredState, { kind: "list" }> {
|
||||
const initial: StoredState = rewindable(address)
|
||||
? { kind: "list", rewind: true, writes: [] }
|
||||
: { kind: "list", rewind: false, elements: new Map() };
|
||||
: { kind: "list", rewind: false, elements: new Map(), known: new Set() };
|
||||
return this.ensureState(address, initial) as Extract<StoredState, { kind: "list" }>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,18 @@
|
|||
import type { Context } from "@earendil-works/chord";
|
||||
import type { ImageContent, Message, TextContent } from "@earendil-works/pi-ai";
|
||||
import type { Element, List, Value } from "./addresses.ts";
|
||||
import type { Id, JsonValue } from "./core.ts";
|
||||
import type { ContextEdit, Conversation, Entry, EntryInput, EntryKind } from "./entries.ts";
|
||||
import type { AssistantMessage, ImageContent, Message, TextContent, ToolCall } from "@earendil-works/pi-ai";
|
||||
import type { Element, List, PayloadOf, ResolvedValue, UnboundList, UnboundValue, Value } from "./addresses.ts";
|
||||
import type { Id, JsonValue, Stored } from "./core.ts";
|
||||
import type { AssistantEntry, ContextEdit, Conversation, Entry, EntryInput, EntryKind, UserEntry } from "./entries.ts";
|
||||
import type { EmptyHookPoints, HookPoints, HookRunner } from "./hooks.ts";
|
||||
import type {
|
||||
AnyDefinedTaskKind,
|
||||
AnyTaskKind,
|
||||
AnyDefinedKind,
|
||||
Completion,
|
||||
ExactJsonInput,
|
||||
InputOf,
|
||||
NoExtra,
|
||||
OrdinaryKind,
|
||||
OutputOf,
|
||||
OutputState,
|
||||
RunningTask,
|
||||
Task,
|
||||
TaskCheckpoint,
|
||||
|
|
@ -24,59 +26,66 @@ export interface TxReaders {
|
|||
getEntry<E extends Entry>(kind: EntryKind<E>, id: Id): Promise<E | undefined>;
|
||||
getEntries(ids: readonly Id[]): Promise<ReadonlyMap<Id, Entry>>;
|
||||
getTask(id: Id): Promise<Task | undefined>;
|
||||
getTask<K extends AnyDefinedTaskKind>(kind: K, id: Id): Promise<TaskOf<K> | undefined>;
|
||||
getTask<K extends AnyDefinedKind>(kind: K, id: Id): Promise<TaskOf<K> | undefined>;
|
||||
getTasks(ids: readonly Id[]): Promise<ReadonlyMap<Id, Task>>;
|
||||
}
|
||||
|
||||
export interface TxValue<T extends JsonValue> {
|
||||
get(at?: Id): Promise<T | undefined>;
|
||||
export interface TxValue<T extends JsonValue, Read = T | undefined> {
|
||||
get(at?: Id): Promise<Read>;
|
||||
set(value: T): void;
|
||||
delete(): void;
|
||||
}
|
||||
|
||||
export interface TxList<T extends JsonValue> {
|
||||
read(): Promise<readonly Element<T>[]>;
|
||||
read(at: Id): Promise<readonly Element<T>[]>;
|
||||
read(at?: Id): Promise<readonly Element<T>[]>;
|
||||
append(value: T): Id;
|
||||
remove(id: Id): void;
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
export interface Acceptance {
|
||||
export interface TxState {
|
||||
value<D extends Value<JsonValue>>(address: D): TxValue<PayloadOf<D>, ResolvedValue<D>>;
|
||||
list<T extends JsonValue>(address: List<T>): TxList<T>;
|
||||
}
|
||||
|
||||
export interface BoundTxState {
|
||||
value<D extends Value<JsonValue> | UnboundValue<JsonValue>>(address: D): TxValue<PayloadOf<D>, ResolvedValue<D>>;
|
||||
list<T extends JsonValue>(address: List<T> | UnboundList<T>): TxList<T>;
|
||||
}
|
||||
|
||||
export type Acceptance = {
|
||||
readonly kind: "send" | "write";
|
||||
readonly requestId?: string;
|
||||
readonly conversationId: Id;
|
||||
readonly inputId: Id;
|
||||
}
|
||||
};
|
||||
|
||||
interface TaskSpecBase<I extends JsonValue> {
|
||||
export type TaskSpec<K extends AnyDefinedKind> = {
|
||||
readonly conversationId?: Id;
|
||||
readonly input: I;
|
||||
readonly input: InputOf<K>;
|
||||
readonly after?: readonly Id[];
|
||||
readonly background?: true;
|
||||
}
|
||||
|
||||
export type TaskSpec<I extends JsonValue, O extends object = never> = TaskSpecBase<I> &
|
||||
([O] extends [never] ? { readonly output?: never } : { readonly output?: TaskOutputRef<O> });
|
||||
} & ([OutputOf<K>] extends [never] ? { readonly output?: never } : { readonly output?: TaskOutputRef<OutputOf<K>> });
|
||||
|
||||
export interface ConversationCreateSpec {
|
||||
readonly parent?: { readonly conversationId: Id; readonly at: Id };
|
||||
readonly parent?: { readonly conversationId: Id; readonly at: Id | "start" };
|
||||
}
|
||||
|
||||
export type UserInput = string | readonly (TextContent | ImageContent)[];
|
||||
|
||||
export interface StoredEntryDraft {
|
||||
export type StoredEntryDraft = {
|
||||
readonly kind: string;
|
||||
readonly data?: JsonValue;
|
||||
readonly model?: readonly Message[];
|
||||
readonly model?: readonly Stored<Message>[];
|
||||
readonly head?: Id | "self";
|
||||
readonly edits?: readonly ContextEdit[];
|
||||
}
|
||||
};
|
||||
|
||||
export type QueuedInput =
|
||||
| { readonly mode: "steer" | "followUp"; readonly input: UserInput; readonly requestId?: string }
|
||||
| { readonly mode: "steer" | "followUp"; readonly input: Stored<UserInput>; readonly requestId?: string }
|
||||
| { readonly mode: "write"; readonly entry: StoredEntryDraft; readonly requestId?: string };
|
||||
|
||||
export type InputResult =
|
||||
export type StoredInputResult =
|
||||
| { readonly status: "queued"; readonly requestId?: string }
|
||||
| { readonly status: "placed"; readonly requestId?: string; readonly entry: Id }
|
||||
| { readonly status: "done"; readonly requestId?: string; readonly entry: Id; readonly answer?: Id }
|
||||
|
|
@ -88,7 +97,24 @@ export type InputResult =
|
|||
readonly detail?: string;
|
||||
};
|
||||
|
||||
export type TerminalInputResult = Extract<InputResult, { status: "done" | "unanswered" }>;
|
||||
export type InputOutcome =
|
||||
| { readonly status: "queued"; readonly requestId?: string }
|
||||
| { readonly status: "placed"; readonly requestId?: string; readonly input: UserEntry }
|
||||
| {
|
||||
readonly status: "done";
|
||||
readonly requestId?: string;
|
||||
readonly input: UserEntry;
|
||||
readonly answer?: { readonly entry: AssistantEntry; readonly message: AssistantMessage };
|
||||
}
|
||||
| {
|
||||
readonly status: "unanswered";
|
||||
readonly requestId?: string;
|
||||
readonly input?: UserEntry;
|
||||
readonly reason: "terminated" | "aborted" | "failed" | "stale";
|
||||
readonly detail?: string;
|
||||
};
|
||||
|
||||
export type TerminalInputOutcome = Extract<InputOutcome, { status: "done" | "unanswered" }>;
|
||||
|
||||
export interface InternalAcceptOptions {
|
||||
readonly input: UserInput;
|
||||
|
|
@ -105,28 +131,36 @@ export interface SendInput {
|
|||
export interface InputHandle {
|
||||
readonly id: Id;
|
||||
readonly conversationId: Id;
|
||||
result(ctx: Context): Promise<InputResult | undefined>;
|
||||
wait(ctx: Context): Promise<TerminalInputResult>;
|
||||
result(ctx: Context): Promise<InputOutcome | undefined>;
|
||||
wait(ctx: Context): Promise<TerminalInputOutcome>;
|
||||
abort(ctx: Context): Promise<"aborted" | "already_placed" | "not_found">;
|
||||
}
|
||||
|
||||
interface TaskCreator<Kinds extends AnyDefinedTaskKind> {
|
||||
task<K extends Kinds, S extends TaskSpec<InputOf<K>, OutputOf<K>>>(
|
||||
interface TaskCreator<Kinds extends AnyDefinedKind, Extra = Record<never, never>> {
|
||||
task<K extends Kinds, S extends TaskSpec<K> & Extra>(
|
||||
kind: K,
|
||||
spec: NoExtra<TaskSpec<InputOf<K>, OutputOf<K>>, S> & {
|
||||
readonly input: ExactJsonInput<InputOf<K>, S["input"]>;
|
||||
},
|
||||
spec: NoExtra<TaskSpec<K> & Extra, S> & { readonly input: ExactJsonInput<InputOf<K>, S["input"]> },
|
||||
): Id;
|
||||
}
|
||||
|
||||
interface TaskTxBase<C extends TaskCheckpoint> extends TxReaders {
|
||||
checkpoint(value: C): void;
|
||||
export interface HostTx extends TxReaders, TxState, TaskCreator<OrdinaryKind, { readonly conversationId: Id }> {
|
||||
createConversation(spec: ConversationCreateSpec): Id;
|
||||
value<T extends JsonValue>(address: Value<T>): TxValue<T>;
|
||||
list<T extends JsonValue>(address: List<T>): TxList<T>;
|
||||
write<E extends Entry>(conversationId: Id, kind: EntryKind<E>, input: EntryInput<E>): Promise<Id>;
|
||||
}
|
||||
|
||||
export type BaseTaskTx<C extends TaskCheckpoint> = TaskTxBase<C> & TaskCreator<AnyTaskKind>;
|
||||
export interface ConversationTx extends TxReaders, BoundTxState, TaskCreator<OrdinaryKind> {
|
||||
createConversation(spec: ConversationCreateSpec): Id;
|
||||
write<E extends Entry>(kind: EntryKind<E>, input: EntryInput<E>): Promise<Id>;
|
||||
}
|
||||
|
||||
interface TaskTxBase<C extends TaskCheckpoint> extends TxReaders, BoundTxState {
|
||||
checkpoint(value: C): void;
|
||||
createConversation(spec: ConversationCreateSpec): Id;
|
||||
}
|
||||
|
||||
export interface BaseTaskTx<C extends TaskCheckpoint> extends TaskTxBase<C>, TaskCreator<OrdinaryKind> {
|
||||
write<E extends Entry>(kind: EntryKind<E>, input: EntryInput<E>): Promise<Id>;
|
||||
}
|
||||
|
||||
export interface InternalAdmissionTx {
|
||||
accept(conversationId: Id, options: InternalAcceptOptions): Promise<Acceptance>;
|
||||
|
|
@ -139,15 +173,21 @@ export interface InternalAdmissionTx {
|
|||
): Promise<Acceptance>;
|
||||
}
|
||||
|
||||
export type CoreTaskTx<C extends TaskCheckpoint> = TaskTxBase<C> &
|
||||
TaskCreator<AnyDefinedTaskKind> &
|
||||
export type CoreTaskTx<C extends TaskCheckpoint> = Omit<BaseTaskTx<C>, "task" | "write"> &
|
||||
TaskCreator<AnyDefinedKind> &
|
||||
InternalAdmissionTx & {
|
||||
entry<E extends Entry>(kind: EntryKind<E>, conversationId: Id, input: EntryInput<E>): Id;
|
||||
};
|
||||
|
||||
export interface PublicValue<T extends JsonValue> {
|
||||
get(ctx: Context): Promise<T | undefined>;
|
||||
get(at: Id, ctx: Context): Promise<T | undefined>;
|
||||
export interface PublicValue<T extends JsonValue, Read = T | undefined> {
|
||||
get(ctx: Context): Promise<Read>;
|
||||
get(at: Id, ctx: Context): Promise<Read>;
|
||||
set(value: T, ctx: Context): Promise<void>;
|
||||
delete(ctx: Context): Promise<void>;
|
||||
}
|
||||
|
||||
export interface StickyPublicValue<T extends JsonValue, Read = T | undefined> {
|
||||
get(ctx: Context): Promise<Read>;
|
||||
set(value: T, ctx: Context): Promise<void>;
|
||||
delete(ctx: Context): Promise<void>;
|
||||
}
|
||||
|
|
@ -160,50 +200,49 @@ export interface PublicList<T extends JsonValue> {
|
|||
clear(ctx: Context): Promise<void>;
|
||||
}
|
||||
|
||||
export interface TaskOutput<O extends object> {
|
||||
export interface BoundPublicState {
|
||||
value<D extends Value<JsonValue> | UnboundValue<JsonValue>>(address: D): PublicValue<PayloadOf<D>, ResolvedValue<D>>;
|
||||
list<T extends JsonValue>(address: List<T> | UnboundList<T>): PublicList<T>;
|
||||
}
|
||||
|
||||
export interface TaskOutput<O extends OutputState> {
|
||||
readonly ref: TaskOutputRef<O>;
|
||||
read(ctx: Context): Promise<O>;
|
||||
mutate(mutator: (state: O) => undefined, ctx: Context): Promise<void>;
|
||||
replace(value: O, ctx: Context): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ReadonlyTaskOutput<O extends object> {
|
||||
export interface ReadonlyTaskOutput<O extends OutputState> {
|
||||
readonly ref: TaskOutputRef<O>;
|
||||
read(ctx: Context): Promise<O>;
|
||||
}
|
||||
|
||||
export type RuntimeOutput<O extends object> = [O] extends [never] ? unknown : { readonly output: TaskOutput<O> };
|
||||
export type AbortRuntimeOutput<O extends object> = [O] extends [never]
|
||||
? unknown
|
||||
export type RuntimeOutput<O extends OutputState> = [O] extends [never]
|
||||
? Record<never, never>
|
||||
: { readonly output: TaskOutput<O> };
|
||||
export type AbortRuntimeOutput<O extends OutputState> = [O] extends [never]
|
||||
? Record<never, never>
|
||||
: { readonly output: ReadonlyTaskOutput<O> };
|
||||
export type FinalOutput<O extends object> = [O] extends [never] ? unknown : { readonly output: O };
|
||||
|
||||
export interface ScratchTx {
|
||||
value<T extends JsonValue>(address: Value<T>): TxValue<T>;
|
||||
list<T extends JsonValue>(address: List<T>): TxList<T>;
|
||||
}
|
||||
export type FinalOutput<O extends OutputState> = [O] extends [never] ? Record<never, never> : { readonly output: O };
|
||||
|
||||
export interface ScratchReader {
|
||||
value<T extends JsonValue>(address: Value<T>): Pick<TxValue<T>, "get">;
|
||||
value<D extends Value<JsonValue>>(address: D): Pick<TxValue<PayloadOf<D>, ResolvedValue<D>>, "get">;
|
||||
list<T extends JsonValue>(address: List<T>): Pick<TxList<T>, "read">;
|
||||
}
|
||||
|
||||
export interface TaskConversation {
|
||||
export interface TaskConversation extends BoundPublicState {
|
||||
readonly id: Id;
|
||||
send(input: SendInput, ctx: Context): Promise<InputHandle>;
|
||||
value<T extends JsonValue>(address: Value<T>): PublicValue<T>;
|
||||
list<T extends JsonValue>(address: List<T>): PublicList<T>;
|
||||
}
|
||||
|
||||
export interface AbortTaskConversation {
|
||||
export interface AbortTaskConversation extends BoundPublicState {
|
||||
readonly id: Id;
|
||||
value<T extends JsonValue>(address: Value<T>): PublicValue<T>;
|
||||
list<T extends JsonValue>(address: List<T>): PublicList<T>;
|
||||
}
|
||||
|
||||
interface TaskRuntimeBase {
|
||||
interface TaskRuntimeBase<H extends HookPoints> {
|
||||
readonly taskId: Id;
|
||||
scratch<T>(build: (tx: ScratchTx) => T | Promise<T>, ctx: Context): Promise<T>;
|
||||
scratch<T>(build: (tx: TxState) => T | Promise<T>, ctx: Context): Promise<T>;
|
||||
readonly hooks: HookRunner<H>;
|
||||
conversation(id: Id, ctx: Context): Promise<TaskConversation | undefined>;
|
||||
waitForTask(id: Id, ctx: Context): Promise<Task>;
|
||||
abortTask(id: Id, ctx: Context): Promise<"marked" | "terminal">;
|
||||
|
|
@ -211,48 +250,43 @@ interface TaskRuntimeBase {
|
|||
sleep(untilMs: number, ctx: Context): Promise<void>;
|
||||
}
|
||||
|
||||
interface CommitRuntime<I extends JsonValue, C extends TaskCheckpoint, O extends object, Tx> {
|
||||
interface CommitRuntime<I extends JsonValue, C extends TaskCheckpoint, O extends OutputState, Tx> {
|
||||
commit<T>(build: (tx: Tx, current: RunningTask<I, C, O>) => T | Promise<T>, ctx: Context): Promise<T>;
|
||||
}
|
||||
|
||||
export type TaskRuntime<I extends JsonValue, C extends TaskCheckpoint, O extends object = never> = TaskRuntimeBase &
|
||||
CommitRuntime<I, C, O, BaseTaskTx<C>> &
|
||||
RuntimeOutput<O>;
|
||||
|
||||
export type CoreTaskRuntime<I extends JsonValue, C extends TaskCheckpoint, O extends object = never> = TaskRuntimeBase &
|
||||
CommitRuntime<I, C, O, CoreTaskTx<C>> &
|
||||
RuntimeOutput<O>;
|
||||
|
||||
type TerminalClosureFor<
|
||||
Tx,
|
||||
export type TaskRuntime<
|
||||
I extends JsonValue,
|
||||
C extends TaskCheckpoint,
|
||||
R extends JsonValue,
|
||||
F extends JsonValue,
|
||||
O extends object,
|
||||
> = (tx: Tx & FinalOutput<O>, current: RunningTask<I, C, O>) => Completion<R, F> | Promise<Completion<R, F>>;
|
||||
O extends OutputState = never,
|
||||
H extends HookPoints = EmptyHookPoints,
|
||||
> = TaskRuntimeBase<H> & CommitRuntime<I, C, O, BaseTaskTx<C>> & RuntimeOutput<O>;
|
||||
|
||||
export type FinalTx<C extends TaskCheckpoint, O extends object> = BaseTaskTx<C> & FinalOutput<O>;
|
||||
export type CoreFinalTx<C extends TaskCheckpoint, O extends object> = CoreTaskTx<C> & FinalOutput<O>;
|
||||
export type CoreTaskRuntime<
|
||||
I extends JsonValue,
|
||||
C extends TaskCheckpoint,
|
||||
O extends OutputState = never,
|
||||
H extends HookPoints = EmptyHookPoints,
|
||||
> = TaskRuntimeBase<H> & CommitRuntime<I, C, O, CoreTaskTx<C>> & RuntimeOutput<O>;
|
||||
|
||||
export type FinalTx<C extends TaskCheckpoint, O extends OutputState> = BaseTaskTx<C> & FinalOutput<O>;
|
||||
export type CoreFinalTx<C extends TaskCheckpoint, O extends OutputState> = CoreTaskTx<C> & FinalOutput<O>;
|
||||
export type TerminalClosure<
|
||||
I extends JsonValue,
|
||||
C extends TaskCheckpoint,
|
||||
R extends JsonValue,
|
||||
F extends JsonValue,
|
||||
O extends object,
|
||||
> = TerminalClosureFor<BaseTaskTx<C>, I, C, R, F, O>;
|
||||
O extends OutputState,
|
||||
> = (tx: FinalTx<C, O>, current: RunningTask<I, C, O>) => Completion<R, F> | Promise<Completion<R, F>>;
|
||||
export type CoreTerminalClosure<
|
||||
I extends JsonValue,
|
||||
C extends TaskCheckpoint,
|
||||
R extends JsonValue,
|
||||
F extends JsonValue,
|
||||
O extends object,
|
||||
> = TerminalClosureFor<CoreTaskTx<C>, I, C, R, F, O>;
|
||||
O extends OutputState,
|
||||
> = (tx: CoreFinalTx<C, O>, current: RunningTask<I, C, O>) => Completion<R, F> | Promise<Completion<R, F>>;
|
||||
|
||||
export interface AbortTx<C extends TaskCheckpoint> extends TxReaders {
|
||||
export interface AbortTx<C extends TaskCheckpoint> extends TxReaders, BoundTxState {
|
||||
checkpoint(value: C): void;
|
||||
value<T extends JsonValue>(address: Value<T>): TxValue<T>;
|
||||
list<T extends JsonValue>(address: List<T>): TxList<T>;
|
||||
markTask(id: Id): Promise<"marked" | "terminal">;
|
||||
}
|
||||
|
||||
|
|
@ -266,43 +300,44 @@ export interface CoreAbortTx<C extends TaskCheckpoint> extends AbortTx<C> {
|
|||
): Promise<Acceptance>;
|
||||
}
|
||||
|
||||
type AbortClosureFor<Tx, I extends JsonValue, C extends TaskCheckpoint, A extends JsonValue, O extends object> = (
|
||||
tx: Tx & FinalOutput<O>,
|
||||
export type AbortFinalTx<C extends TaskCheckpoint, O extends OutputState> = AbortTx<C> & FinalOutput<O>;
|
||||
export type CoreAbortFinalTx<C extends TaskCheckpoint, O extends OutputState> = CoreAbortTx<C> & FinalOutput<O>;
|
||||
export type AbortClosure<I extends JsonValue, C extends TaskCheckpoint, A extends JsonValue, O extends OutputState> = (
|
||||
tx: AbortFinalTx<C, O>,
|
||||
current: RunningTask<I, C, O>,
|
||||
) => A | Promise<A>;
|
||||
|
||||
export type AbortFinalTx<C extends TaskCheckpoint, O extends object> = AbortTx<C> & FinalOutput<O>;
|
||||
export type CoreAbortFinalTx<C extends TaskCheckpoint, O extends object> = CoreAbortTx<C> & FinalOutput<O>;
|
||||
export type AbortClosure<
|
||||
I extends JsonValue,
|
||||
C extends TaskCheckpoint,
|
||||
A extends JsonValue,
|
||||
O extends object,
|
||||
> = AbortClosureFor<AbortTx<C>, I, C, A, O>;
|
||||
export type CoreAbortClosure<
|
||||
I extends JsonValue,
|
||||
C extends TaskCheckpoint,
|
||||
A extends JsonValue,
|
||||
O extends object,
|
||||
> = AbortClosureFor<CoreAbortTx<C>, I, C, A, O>;
|
||||
O extends OutputState,
|
||||
> = (tx: CoreAbortFinalTx<C, O>, current: RunningTask<I, C, O>) => A | Promise<A>;
|
||||
|
||||
interface AbortRuntimeBase {
|
||||
interface AbortRuntimeBase<H extends HookPoints> {
|
||||
readonly taskId: Id;
|
||||
scratch<T>(read: (scratch: ScratchReader) => T | Promise<T>, ctx: Context): Promise<T>;
|
||||
readonly hooks: HookRunner<H>;
|
||||
conversation(id: Id, ctx: Context): Promise<AbortTaskConversation | undefined>;
|
||||
waitForTask(id: Id, ctx: Context): Promise<Task>;
|
||||
abortTask(id: Id, ctx: Context): Promise<"marked" | "terminal">;
|
||||
now(): number;
|
||||
sleep(untilMs: number, ctx: Context): Promise<void>;
|
||||
}
|
||||
|
||||
export type AbortTaskRuntime<
|
||||
I extends JsonValue,
|
||||
C extends TaskCheckpoint,
|
||||
O extends object = never,
|
||||
> = AbortRuntimeBase & CommitRuntime<I, C, O, AbortTx<C>> & AbortRuntimeOutput<O>;
|
||||
O extends OutputState = never,
|
||||
H extends HookPoints = EmptyHookPoints,
|
||||
> = AbortRuntimeBase<H> & CommitRuntime<I, C, O, AbortTx<C>> & AbortRuntimeOutput<O>;
|
||||
|
||||
export type CoreAbortTaskRuntime<
|
||||
I extends JsonValue,
|
||||
C extends TaskCheckpoint,
|
||||
O extends object = never,
|
||||
> = AbortRuntimeBase & CommitRuntime<I, C, O, CoreAbortTx<C>> & AbortRuntimeOutput<O>;
|
||||
O extends OutputState = never,
|
||||
H extends HookPoints = EmptyHookPoints,
|
||||
> = AbortRuntimeBase<H> & CommitRuntime<I, C, O, CoreAbortTx<C>> & AbortRuntimeOutput<O>;
|
||||
|
||||
export type ToolInput = { readonly assistant: Id; readonly call: Stored<ToolCall> };
|
||||
export type GenerationOutput = { message?: Stored<AssistantMessage> };
|
||||
export type ToolOutputState = { progress?: string; log: string[]; details?: JsonValue };
|
||||
|
|
|
|||
|
|
@ -1,10 +1,33 @@
|
|||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import type { Context } from "@earendil-works/chord";
|
||||
import type { Address, Element, List, Value } from "./addresses.ts";
|
||||
import { withoutAbortSignal } from "@earendil-works/chord/context";
|
||||
import type { Element, List, PayloadOf, ResolvedValue, Value } from "./addresses.ts";
|
||||
import type { Id, JsonValue } from "./core.ts";
|
||||
import type { Conversation, Entry } from "./entries.ts";
|
||||
import type { ConversationScan, EntryScan, NewTask, Page, Storage, TaskScan, Write } from "./storage.ts";
|
||||
import type { Task } from "./tasks.ts";
|
||||
|
||||
export class Closed extends Error {
|
||||
constructor() {
|
||||
super("Session is closed");
|
||||
this.name = "Closed";
|
||||
}
|
||||
}
|
||||
|
||||
export class Faulted extends Error {
|
||||
constructor() {
|
||||
super("Session is faulted");
|
||||
this.name = "Faulted";
|
||||
}
|
||||
}
|
||||
|
||||
export class NestedLineOperation extends Error {
|
||||
constructor() {
|
||||
super("Session line operations cannot be nested");
|
||||
this.name = "NestedLineOperation";
|
||||
}
|
||||
}
|
||||
|
||||
export class ReadAfterWrite extends Error {
|
||||
constructor() {
|
||||
super("Transaction reads must precede writes");
|
||||
|
|
@ -12,30 +35,29 @@ export class ReadAfterWrite extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
export class ScratchRetired extends Error {
|
||||
readonly taskId: Id;
|
||||
|
||||
constructor(taskId: Id) {
|
||||
super(`Scratch for task ${taskId} is retired`);
|
||||
this.name = "ScratchRetired";
|
||||
this.taskId = taskId;
|
||||
}
|
||||
}
|
||||
|
||||
export class SharedOutputRetired extends Error {
|
||||
readonly outputId: Id;
|
||||
|
||||
constructor(outputId: Id) {
|
||||
super(`Shared task output ${outputId} is retired`);
|
||||
this.name = "SharedOutputRetired";
|
||||
this.outputId = outputId;
|
||||
}
|
||||
}
|
||||
|
||||
export type TaskCreate = Omit<NewTask, "id" | "output"> & {
|
||||
readonly output?: { readonly id?: Id; readonly kind: string };
|
||||
};
|
||||
|
||||
interface LineInvocation {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
const lineInvocation = new AsyncLocalStorage<LineInvocation>();
|
||||
const storageOwners = new WeakMap<Storage, object>();
|
||||
|
||||
function assertContextActive(ctx: Context): void {
|
||||
ctx.abortSignal?.throwIfAborted();
|
||||
}
|
||||
|
||||
function resolveStoredValue<D extends Value<JsonValue>>(
|
||||
address: D,
|
||||
stored: PayloadOf<D> | undefined,
|
||||
): ResolvedValue<D> {
|
||||
const fallback = address.default as PayloadOf<D> | undefined;
|
||||
return (stored === undefined ? fallback : stored) as ResolvedValue<D>;
|
||||
}
|
||||
|
||||
export interface Transaction {
|
||||
getConversation(id: Id): Promise<Conversation | undefined>;
|
||||
getEntry(id: Id): Promise<Entry | undefined>;
|
||||
|
|
@ -46,11 +68,11 @@ export interface Transaction {
|
|||
entry(entry: Omit<Entry, "id">): Id;
|
||||
task(task: TaskCreate): Id;
|
||||
setTask(task: Task): void;
|
||||
value<T extends JsonValue>(
|
||||
address: Value<T>,
|
||||
value<D extends Value<JsonValue>>(
|
||||
address: D,
|
||||
): {
|
||||
get(at?: Id): Promise<T | undefined>;
|
||||
set(value: T): void;
|
||||
get(at?: Id): Promise<ResolvedValue<D>>;
|
||||
set(value: PayloadOf<D>): void;
|
||||
delete(): void;
|
||||
};
|
||||
list<T extends JsonValue>(
|
||||
|
|
@ -68,7 +90,9 @@ class TransactionState {
|
|||
private readonly ctx: Context;
|
||||
private active = true;
|
||||
private writing = false;
|
||||
private entryAppended = false;
|
||||
private pendingReads = 0;
|
||||
private failure: Error | undefined;
|
||||
private readonly writes: Write[] = [];
|
||||
|
||||
constructor(storage: Storage, ctx: Context) {
|
||||
|
|
@ -87,14 +111,14 @@ class TransactionState {
|
|||
entry: (entry: Omit<Entry, "id">) => this.entry(entry),
|
||||
task: (task: TaskCreate) => this.task(task),
|
||||
setTask: (task: Task) => this.setTask(task),
|
||||
value: <T extends JsonValue>(address: Value<T>) => this.value(address),
|
||||
value: <D extends Value<JsonValue>>(address: D) => this.value(address),
|
||||
list: <T extends JsonValue>(address: List<T>) => this.list(address),
|
||||
});
|
||||
}
|
||||
|
||||
finish(): readonly Write[] {
|
||||
this.assertActive();
|
||||
if (this.pendingReads !== 0) throw new Error("Transaction has pending reads");
|
||||
if (this.pendingReads !== 0) this.poison(new Error("Transaction has pending reads"));
|
||||
this.active = false;
|
||||
return this.writes;
|
||||
}
|
||||
|
|
@ -132,6 +156,7 @@ class TransactionState {
|
|||
entry(entry: Omit<Entry, "id">): Id {
|
||||
const id = this.allocate();
|
||||
this.writes.push({ type: "entry.append", entry: { ...entry, id } });
|
||||
this.entryAppended = true;
|
||||
return id;
|
||||
}
|
||||
|
||||
|
|
@ -151,18 +176,28 @@ class TransactionState {
|
|||
this.write({ type: "task.set", task });
|
||||
}
|
||||
|
||||
value<T extends JsonValue>(
|
||||
address: Value<T>,
|
||||
value<D extends Value<JsonValue>>(
|
||||
address: D,
|
||||
): {
|
||||
get(at?: Id): Promise<T | undefined>;
|
||||
set(value: T): void;
|
||||
get(at?: Id): Promise<ResolvedValue<D>>;
|
||||
set(value: PayloadOf<D>): void;
|
||||
delete(): void;
|
||||
} {
|
||||
this.assertActive();
|
||||
return Object.freeze({
|
||||
get: (at?: Id) => this.read(() => this.storage.getValue(address, at, this.ctx)),
|
||||
set: (value: T) => this.write({ type: "value.set", address, value }),
|
||||
delete: () => this.write({ type: "value.delete", address }),
|
||||
get: (at?: Id) =>
|
||||
this.read(async () => {
|
||||
const stored = await this.storage.getValue(address as unknown as Value<PayloadOf<D>>, at, this.ctx);
|
||||
return resolveStoredValue(address, stored);
|
||||
}),
|
||||
set: (value: PayloadOf<D>) => {
|
||||
this.assertStateWriteOrder(address);
|
||||
this.write({ type: "value.set", address, value });
|
||||
},
|
||||
delete: () => {
|
||||
this.assertStateWriteOrder(address);
|
||||
this.write({ type: "value.delete", address });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -178,15 +213,29 @@ class TransactionState {
|
|||
return Object.freeze({
|
||||
read: (at?: Id) => this.read(() => this.storage.readList(address, at, this.ctx)),
|
||||
append: (value: T) => {
|
||||
this.assertStateWriteOrder(address);
|
||||
const id = this.allocate();
|
||||
this.writes.push({ type: "list.append", address, element: { id, value } });
|
||||
return id;
|
||||
},
|
||||
remove: (elementId: Id) => this.write({ type: "list.remove", address, elementId }),
|
||||
clear: () => this.write({ type: "list.clear", address }),
|
||||
remove: (elementId: Id) => {
|
||||
this.assertStateWriteOrder(address);
|
||||
this.write({ type: "list.remove", address, elementId });
|
||||
},
|
||||
clear: () => {
|
||||
this.assertStateWriteOrder(address);
|
||||
this.write({ type: "list.clear", address });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private assertStateWriteOrder(address: Value<JsonValue> | List<JsonValue>): void {
|
||||
this.assertActive();
|
||||
if (address.rewind && this.entryAppended) {
|
||||
this.poison(new Error("Rewindable state writes must precede entry appends"));
|
||||
}
|
||||
}
|
||||
|
||||
private allocate(): Id {
|
||||
this.assertWritable();
|
||||
return this.storage.nextId();
|
||||
|
|
@ -209,102 +258,183 @@ class TransactionState {
|
|||
|
||||
private assertReadable(): void {
|
||||
this.assertActive();
|
||||
if (this.writing) throw new ReadAfterWrite();
|
||||
if (this.writing) this.poison(new ReadAfterWrite());
|
||||
}
|
||||
|
||||
private assertWritable(): void {
|
||||
this.assertActive();
|
||||
if (this.pendingReads !== 0) throw new Error("Transaction writes must await reads");
|
||||
if (this.pendingReads !== 0) this.poison(new Error("Transaction writes must await reads"));
|
||||
this.writing = true;
|
||||
}
|
||||
|
||||
private assertActive(): void {
|
||||
if (!this.active) throw new Error("Transaction is closed");
|
||||
if (this.failure !== undefined) throw this.failure;
|
||||
}
|
||||
|
||||
private poison(error: Error): never {
|
||||
this.failure ??= error;
|
||||
throw this.failure;
|
||||
}
|
||||
}
|
||||
|
||||
export class Session {
|
||||
private readonly storage: Storage;
|
||||
private readonly ownership = Object.freeze({});
|
||||
private tail: Promise<void> = Promise.resolve();
|
||||
private fault: Faulted | undefined;
|
||||
private closed = false;
|
||||
private closeCompletion: Promise<void> | undefined;
|
||||
private storageCloseCompletion: Promise<void> | undefined;
|
||||
|
||||
constructor(storage: Storage) {
|
||||
if (storageOwners.has(storage)) throw new Error("Storage is already owned by another Session");
|
||||
storageOwners.set(storage, this.ownership);
|
||||
this.storage = storage;
|
||||
}
|
||||
|
||||
async commit<T>(build: (tx: Transaction) => T | Promise<T>, ctx: Context): Promise<T> {
|
||||
commit<T>(build: (tx: Transaction) => T | Promise<T>, ctx: Context): Promise<T> {
|
||||
try {
|
||||
assertContextActive(ctx);
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
return this.onLine(async () => {
|
||||
this.assertUsable();
|
||||
assertContextActive(ctx);
|
||||
const tx = new TransactionState(this.storage, ctx);
|
||||
try {
|
||||
const result = await build(tx.facade());
|
||||
const writes = tx.finish();
|
||||
if (writes.length !== 0) {
|
||||
try {
|
||||
await this.storage.commit(writes, withoutAbortSignal(ctx));
|
||||
} catch (error) {
|
||||
this.fault = new Faulted();
|
||||
try {
|
||||
await this.closeStorage(ctx);
|
||||
} catch {}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
tx.seal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
close(ctx: Context): Promise<void> {
|
||||
if (this.nested()) return Promise.reject(new NestedLineOperation());
|
||||
this.closeCompletion ??= this.enqueueLine(async () => {
|
||||
this.closed = true;
|
||||
await this.closeStorage(ctx);
|
||||
});
|
||||
return this.closeCompletion;
|
||||
}
|
||||
|
||||
getConversations(ids: readonly Id[], ctx: Context): Promise<ReadonlyMap<Id, Conversation>> {
|
||||
return this.onLine(() => {
|
||||
this.assertUsable();
|
||||
return this.storage.getConversations(ids, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
scanConversations(query: ConversationScan, ctx: Context): Promise<Page<Conversation>> {
|
||||
return this.onLine(() => {
|
||||
this.assertUsable();
|
||||
return this.storage.scanConversations(query, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
getEntries(ids: readonly Id[], ctx: Context): Promise<ReadonlyMap<Id, Entry>> {
|
||||
return this.onLine(() => {
|
||||
this.assertUsable();
|
||||
return this.storage.getEntries(ids, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
scanEntries(query: EntryScan, ctx: Context): Promise<Page<Entry>> {
|
||||
return this.onLine(() => {
|
||||
this.assertUsable();
|
||||
return this.storage.scanEntries(query, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
newestHead(conversationId: Id, at: Id, ctx: Context): Promise<Entry | undefined> {
|
||||
return this.onLine(() => {
|
||||
this.assertUsable();
|
||||
return this.storage.newestHead(conversationId, at, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
getTasks(ids: readonly Id[], ctx: Context): Promise<ReadonlyMap<Id, Task>> {
|
||||
return this.onLine(() => {
|
||||
this.assertUsable();
|
||||
return this.storage.getTasks(ids, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
scanTasks(query: TaskScan, ctx: Context): Promise<Page<Task>> {
|
||||
return this.onLine(() => {
|
||||
this.assertUsable();
|
||||
return this.storage.scanTasks(query, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
getValue<D extends Value<JsonValue>>(address: D, at: Id | undefined, ctx: Context): Promise<ResolvedValue<D>> {
|
||||
return this.onLine(async () => {
|
||||
this.assertUsable();
|
||||
const stored = await this.storage.getValue(address as unknown as Value<PayloadOf<D>>, at, ctx);
|
||||
return resolveStoredValue(address, stored);
|
||||
});
|
||||
}
|
||||
|
||||
readList<T extends JsonValue>(address: List<T>, at: Id | undefined, ctx: Context): Promise<readonly Element<T>[]> {
|
||||
return this.onLine(() => {
|
||||
this.assertUsable();
|
||||
return this.storage.readList(address, at, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
private onLine<T>(operation: () => T | Promise<T>): Promise<T> {
|
||||
if (this.nested()) return Promise.reject(new NestedLineOperation());
|
||||
return this.enqueueLine(operation);
|
||||
}
|
||||
|
||||
private enqueueLine<T>(operation: () => T | Promise<T>): Promise<T> {
|
||||
const previous = this.tail;
|
||||
let release!: () => void;
|
||||
this.tail = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await previous;
|
||||
const tx = new TransactionState(this.storage, ctx);
|
||||
try {
|
||||
const result = await build(tx.facade());
|
||||
const writes = tx.finish();
|
||||
if (writes.length !== 0) await this.storage.commit(writes, ctx);
|
||||
return result;
|
||||
} finally {
|
||||
tx.seal();
|
||||
release();
|
||||
}
|
||||
return (async () => {
|
||||
await previous;
|
||||
const invocation: LineInvocation = { active: true };
|
||||
try {
|
||||
return await lineInvocation.run(invocation, operation);
|
||||
} finally {
|
||||
invocation.active = false;
|
||||
release();
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
getConversations(ids: readonly Id[], ctx: Context): Promise<ReadonlyMap<Id, Conversation>> {
|
||||
return this.storage.getConversations(ids, ctx);
|
||||
private nested(): boolean {
|
||||
return lineInvocation.getStore()?.active === true;
|
||||
}
|
||||
|
||||
scanConversations(query: ConversationScan, ctx: Context): Promise<Page<Conversation>> {
|
||||
return this.storage.scanConversations(query, ctx);
|
||||
private closeStorage(ctx: Context): Promise<void> {
|
||||
this.storageCloseCompletion ??= Promise.resolve()
|
||||
.then(() => this.storage.close(withoutAbortSignal(ctx)))
|
||||
.then(() => {
|
||||
if (storageOwners.get(this.storage) === this.ownership) storageOwners.delete(this.storage);
|
||||
});
|
||||
return this.storageCloseCompletion;
|
||||
}
|
||||
|
||||
getEntries(ids: readonly Id[], ctx: Context): Promise<ReadonlyMap<Id, Entry>> {
|
||||
return this.storage.getEntries(ids, ctx);
|
||||
}
|
||||
|
||||
scanEntries(query: EntryScan, ctx: Context): Promise<Page<Entry>> {
|
||||
return this.storage.scanEntries(query, ctx);
|
||||
}
|
||||
|
||||
newestHead(conversationId: Id, at: Id, ctx: Context): Promise<Entry | undefined> {
|
||||
return this.storage.newestHead(conversationId, at, ctx);
|
||||
}
|
||||
|
||||
getTasks(ids: readonly Id[], ctx: Context): Promise<ReadonlyMap<Id, Task>> {
|
||||
return this.storage.getTasks(ids, ctx);
|
||||
}
|
||||
|
||||
scanTasks(query: TaskScan, ctx: Context): Promise<Page<Task>> {
|
||||
return this.storage.scanTasks(query, ctx);
|
||||
}
|
||||
|
||||
async getValue<T extends JsonValue>(address: Value<T>, at: Id | undefined, ctx: Context): Promise<T | undefined> {
|
||||
await this.assertReadable(address, ctx);
|
||||
return this.storage.getValue(address, at, ctx);
|
||||
}
|
||||
|
||||
async readList<T extends JsonValue>(
|
||||
address: List<T>,
|
||||
at: Id | undefined,
|
||||
ctx: Context,
|
||||
): Promise<readonly Element<T>[]> {
|
||||
await this.assertReadable(address, ctx);
|
||||
return this.storage.readList(address, at, ctx);
|
||||
}
|
||||
|
||||
private async outputIsLive(id: Id, ctx: Context): Promise<boolean> {
|
||||
return (
|
||||
(await this.storage.scanTasks({ outputId: id, statuses: ["pending", "running"], limit: 1 }, ctx)).items
|
||||
.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
private async assertReadable(address: Address, ctx: Context): Promise<void> {
|
||||
if (address.scope.type === "task") {
|
||||
const task = (await this.storage.getTasks([address.scope.taskId], ctx)).get(address.scope.taskId);
|
||||
if (task === undefined || task.status === "terminal") throw new ScratchRetired(address.scope.taskId);
|
||||
} else if (address.scope.type === "shared" && !(await this.outputIsLive(address.scope.id, ctx))) {
|
||||
throw new SharedOutputRetired(address.scope.id);
|
||||
}
|
||||
private assertUsable(): void {
|
||||
if (this.fault !== undefined) throw this.fault;
|
||||
if (this.closed) throw new Closed();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import type { Context } from "@earendil-works/chord";
|
||||
import type { Element, List, Value } from "./addresses.ts";
|
||||
import type { Id, JsonValue, Seq } from "./core.ts";
|
||||
import type { Conversation, Entry } from "./entries.ts";
|
||||
import type { Task } from "./tasks.ts";
|
||||
import type { Conversation, Entry, SectionRecord } from "./entries.ts";
|
||||
import type { Task, TaskBase } from "./tasks.ts";
|
||||
|
||||
export interface PageQuery {
|
||||
/** Last item returned by the same scan. */
|
||||
|
|
@ -15,6 +15,26 @@ export interface Page<T> {
|
|||
readonly next?: Id;
|
||||
}
|
||||
|
||||
export class ScratchRetired extends Error {
|
||||
readonly taskId: Id;
|
||||
|
||||
constructor(taskId: Id) {
|
||||
super(`Scratch for task ${taskId} is retired`);
|
||||
this.name = "ScratchRetired";
|
||||
this.taskId = taskId;
|
||||
}
|
||||
}
|
||||
|
||||
export class OutputRetired extends Error {
|
||||
readonly outputId: Id;
|
||||
|
||||
constructor(outputId: Id) {
|
||||
super(`Task output ${outputId} is retired`);
|
||||
this.name = "OutputRetired";
|
||||
this.outputId = outputId;
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidHistoryPosition extends Error {
|
||||
readonly at: Id;
|
||||
|
||||
|
|
@ -25,7 +45,7 @@ export class InvalidHistoryPosition extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
export type NewTask = Omit<Task, "abort" | "checkpoint" | "outcome" | "owns" | "status"> & {
|
||||
export type NewTask = Omit<TaskBase, "abort" | "checkpoint" | "owns"> & {
|
||||
readonly status: "pending";
|
||||
readonly owns: readonly [];
|
||||
readonly abort?: never;
|
||||
|
|
@ -40,9 +60,11 @@ export type StateWrite =
|
|||
| { readonly type: "list.remove"; readonly address: List<JsonValue>; readonly elementId: Id }
|
||||
| { readonly type: "list.clear"; readonly address: List<JsonValue> };
|
||||
|
||||
export type StoredConversation = Conversation & { readonly sectionSeed?: readonly SectionRecord[] };
|
||||
|
||||
export type Write =
|
||||
| StateWrite
|
||||
| { readonly type: "conversation.create"; readonly conversation: Conversation }
|
||||
| { readonly type: "conversation.create"; readonly conversation: StoredConversation }
|
||||
| { readonly type: "entry.append"; readonly entry: Entry }
|
||||
| { readonly type: "task.create"; readonly task: NewTask }
|
||||
| { readonly type: "task.set"; readonly task: Task };
|
||||
|
|
@ -78,4 +100,5 @@ export interface Storage {
|
|||
scanTasks(query: TaskScan, ctx: Context): Promise<Page<Task>>;
|
||||
getValue<T extends JsonValue>(address: Value<T>, at: Id | undefined, ctx: Context): Promise<T | undefined>;
|
||||
readList<T extends JsonValue>(address: List<T>, at: Id | undefined, ctx: Context): Promise<readonly Element<T>[]>;
|
||||
close(ctx: Context): Promise<void>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import type { Context } from "@earendil-works/chord";
|
||||
import type { ConversationValueDefinition } from "./addresses.ts";
|
||||
import type { Id, JsonObject, JsonValue } from "./core.ts";
|
||||
import type { EmptyHookPoints, HookPoints } from "./hooks.ts";
|
||||
import type {
|
||||
AbortClosure,
|
||||
AbortTaskRuntime,
|
||||
|
|
@ -16,45 +18,64 @@ export interface TaskCheckpoint extends JsonObject {
|
|||
}
|
||||
|
||||
export type NoCheckpoint = never;
|
||||
export type OutputState = JsonObject | JsonValue[];
|
||||
export type ConfigBundle = Record<string, ConversationValueDefinition<JsonValue>>;
|
||||
export type ReservedConfigKeys = "set" | "get";
|
||||
export type KeysOf<U> = U extends unknown ? keyof U : never;
|
||||
export type Collides<M, N extends keyof M, Reserved extends PropertyKey> = [
|
||||
Extract<keyof M[N], KeysOf<M[Exclude<keyof M, N>]> | Reserved>,
|
||||
] extends [never]
|
||||
? false
|
||||
: true;
|
||||
export type AnyCollision<M, Reserved extends PropertyKey> = true extends {
|
||||
[N in keyof M]: Collides<M, N, Reserved>;
|
||||
}[keyof M]
|
||||
? true
|
||||
: false;
|
||||
export type DisjointBundles<M, Reserved extends PropertyKey = never> = AnyCollision<M, Reserved> extends false
|
||||
? true
|
||||
: false;
|
||||
|
||||
declare const taskOutputType: unique symbol;
|
||||
const taskKindBrand: unique symbol = Symbol("pico.taskKind");
|
||||
|
||||
export interface TaskOutputKind<O extends object> {
|
||||
export interface TaskOutputKind<O extends OutputState> {
|
||||
readonly kind: string;
|
||||
readonly [taskOutputType]?: O;
|
||||
}
|
||||
|
||||
export function defineTaskOutput<O extends object>(kind: string): TaskOutputKind<O> {
|
||||
export function defineTaskOutput<O extends OutputState>(kind: string): TaskOutputKind<O> {
|
||||
return Object.freeze({ kind });
|
||||
}
|
||||
|
||||
export interface TaskOutputSpec<I extends JsonValue, O extends object> {
|
||||
readonly kind: TaskOutputKind<O>;
|
||||
initial(input: I): O;
|
||||
}
|
||||
|
||||
export interface StoredTaskOutputRef {
|
||||
readonly id: Id;
|
||||
readonly kind: string;
|
||||
}
|
||||
|
||||
export interface TaskOutputRef<O extends object> extends StoredTaskOutputRef {
|
||||
export interface TaskOutputRef<O extends OutputState> extends StoredTaskOutputRef {
|
||||
readonly [taskOutputType]?: O;
|
||||
}
|
||||
|
||||
export type TaskOutputField<O extends object> = [O] extends [never] ? unknown : { readonly output: TaskOutputRef<O> };
|
||||
export type TaskOutputField<O extends OutputState> = [O] extends [never]
|
||||
? Record<never, never>
|
||||
: { readonly output: TaskOutputRef<O> };
|
||||
|
||||
export type TaskOutputDefinition<I extends JsonValue, O extends object> = [O] extends [never]
|
||||
export type TaskOutputDefinition<I extends JsonValue, O extends OutputState> = [O] extends [never]
|
||||
? { readonly output?: never }
|
||||
: { readonly output: TaskOutputSpec<I, O> };
|
||||
: { readonly output: { readonly kind: TaskOutputKind<O>; initial(input: I): O } };
|
||||
|
||||
export type TaskOutcome<R extends JsonValue, F extends JsonValue, A extends JsonValue> =
|
||||
export type TaskOutcome<
|
||||
R extends JsonValue = JsonValue,
|
||||
F extends JsonValue = JsonValue,
|
||||
A extends JsonValue = JsonValue,
|
||||
> =
|
||||
| { readonly status: "completed"; readonly result: R }
|
||||
| { readonly status: "failed"; readonly failure: F }
|
||||
| { readonly status: "aborted"; readonly result: A }
|
||||
| { readonly status: "orphaned" };
|
||||
|
||||
export interface TaskBase<I extends JsonValue, C extends TaskCheckpoint> {
|
||||
export interface TaskBase<I extends JsonValue = JsonValue, C extends TaskCheckpoint = TaskCheckpoint> {
|
||||
readonly id: Id;
|
||||
readonly conversationId: Id;
|
||||
readonly kind: string;
|
||||
|
|
@ -79,7 +100,7 @@ export type Task<
|
|||
| { readonly status: "terminal"; readonly outcome: TaskOutcome<R, F, A> }
|
||||
);
|
||||
|
||||
export type RunningTask<I extends JsonValue, C extends TaskCheckpoint, O extends object = never> = Omit<
|
||||
export type RunningTask<I extends JsonValue, C extends TaskCheckpoint, O extends OutputState = never> = Omit<
|
||||
TaskBase<I, C>,
|
||||
"output"
|
||||
> &
|
||||
|
|
@ -92,39 +113,31 @@ export type Completion<R extends JsonValue, F extends JsonValue> =
|
|||
| { readonly status: "completed"; readonly result: R }
|
||||
| { readonly status: "failed"; readonly failure: F };
|
||||
|
||||
const taskKindBrand: unique symbol = Symbol("pico.taskKind");
|
||||
|
||||
export interface TaskKindBase {
|
||||
readonly kind: string;
|
||||
readonly [taskKindBrand]: "ordinary";
|
||||
}
|
||||
|
||||
export interface CoreTaskKindBase {
|
||||
readonly kind: string;
|
||||
readonly [taskKindBrand]: "core";
|
||||
}
|
||||
|
||||
interface TaskKindMethods<
|
||||
I extends JsonValue,
|
||||
C extends TaskCheckpoint,
|
||||
R extends JsonValue,
|
||||
F extends JsonValue,
|
||||
A extends JsonValue,
|
||||
O extends object,
|
||||
> extends TaskKindBase {
|
||||
O extends OutputState,
|
||||
H extends HookPoints,
|
||||
> {
|
||||
readonly kind: string;
|
||||
readonly hooks?: H;
|
||||
readonly config?: ConfigBundle;
|
||||
execute(
|
||||
task: RunningTask<I, C, O>,
|
||||
runtime: TaskRuntime<I, C, O>,
|
||||
runtime: TaskRuntime<I, C, O, H>,
|
||||
ctx: Context,
|
||||
): Promise<TerminalClosure<I, C, R, F, O>>;
|
||||
recover(
|
||||
task: RunningTask<I, C, O>,
|
||||
runtime: TaskRuntime<I, C, O>,
|
||||
runtime: TaskRuntime<I, C, O, H>,
|
||||
ctx: Context,
|
||||
): Promise<TerminalClosure<I, C, R, F, O>>;
|
||||
abort(
|
||||
task: RunningTask<I, C, O>,
|
||||
runtime: AbortTaskRuntime<I, C, O>,
|
||||
runtime: AbortTaskRuntime<I, C, O, H>,
|
||||
ctx: Context,
|
||||
): Promise<AbortClosure<I, C, A, O>>;
|
||||
}
|
||||
|
|
@ -135,21 +148,25 @@ interface CoreTaskKindMethods<
|
|||
R extends JsonValue,
|
||||
F extends JsonValue,
|
||||
A extends JsonValue,
|
||||
O extends object,
|
||||
> extends CoreTaskKindBase {
|
||||
O extends OutputState,
|
||||
H extends HookPoints,
|
||||
> {
|
||||
readonly kind: string;
|
||||
readonly hooks?: H;
|
||||
readonly config?: ConfigBundle;
|
||||
execute(
|
||||
task: RunningTask<I, C, O>,
|
||||
runtime: CoreTaskRuntime<I, C, O>,
|
||||
runtime: CoreTaskRuntime<I, C, O, H>,
|
||||
ctx: Context,
|
||||
): Promise<CoreTerminalClosure<I, C, R, F, O>>;
|
||||
recover(
|
||||
task: RunningTask<I, C, O>,
|
||||
runtime: CoreTaskRuntime<I, C, O>,
|
||||
runtime: CoreTaskRuntime<I, C, O, H>,
|
||||
ctx: Context,
|
||||
): Promise<CoreTerminalClosure<I, C, R, F, O>>;
|
||||
abort(
|
||||
task: RunningTask<I, C, O>,
|
||||
runtime: CoreAbortTaskRuntime<I, C, O>,
|
||||
runtime: CoreAbortTaskRuntime<I, C, O, H>,
|
||||
ctx: Context,
|
||||
): Promise<CoreAbortClosure<I, C, A, O>>;
|
||||
}
|
||||
|
|
@ -160,8 +177,9 @@ export type TaskKind<
|
|||
R extends JsonValue,
|
||||
F extends JsonValue,
|
||||
A extends JsonValue,
|
||||
O extends object = never,
|
||||
> = TaskKindMethods<I, C, R, F, A, O> & TaskOutputDefinition<I, O>;
|
||||
O extends OutputState = never,
|
||||
H extends HookPoints = EmptyHookPoints,
|
||||
> = TaskKindMethods<I, C, R, F, A, O, H> & TaskOutputDefinition<I, O> & { readonly [taskKindBrand]: "ordinary" };
|
||||
|
||||
export type CoreTaskKind<
|
||||
I extends JsonValue,
|
||||
|
|
@ -169,19 +187,37 @@ export type CoreTaskKind<
|
|||
R extends JsonValue,
|
||||
F extends JsonValue,
|
||||
A extends JsonValue,
|
||||
O extends object = never,
|
||||
> = CoreTaskKindMethods<I, C, R, F, A, O> & TaskOutputDefinition<I, O>;
|
||||
O extends OutputState = never,
|
||||
H extends HookPoints = EmptyHookPoints,
|
||||
> = CoreTaskKindMethods<I, C, R, F, A, O, H> & TaskOutputDefinition<I, O> & { readonly [taskKindBrand]: "core" };
|
||||
|
||||
export type NoExtra<Expected, Actual extends Expected> = Actual & Record<Exclude<keyof Actual, keyof Expected>, never>;
|
||||
|
||||
export type ExactJsonInput<Expected extends JsonValue, Actual extends Expected> = Expected extends readonly JsonValue[]
|
||||
? Actual
|
||||
: Expected extends JsonObject
|
||||
? Actual & Record<Exclude<keyof Actual, keyof Expected>, never>
|
||||
? NoExtra<Expected, Actual>
|
||||
: Actual;
|
||||
|
||||
type KindDefinition<K> = Omit<K, typeof taskKindBrand>;
|
||||
type KindFactory<K> = <D extends KindDefinition<K>>(definition: NoExtra<KindDefinition<K>, D>) => D & K;
|
||||
export type TaskKindDefinition<
|
||||
I extends JsonValue,
|
||||
C extends TaskCheckpoint,
|
||||
R extends JsonValue,
|
||||
F extends JsonValue,
|
||||
A extends JsonValue,
|
||||
O extends OutputState,
|
||||
H extends HookPoints,
|
||||
> = Omit<TaskKind<I, C, R, F, A, O, H>, typeof taskKindBrand>;
|
||||
|
||||
export type CoreTaskKindDefinition<
|
||||
I extends JsonValue,
|
||||
C extends TaskCheckpoint,
|
||||
R extends JsonValue,
|
||||
F extends JsonValue,
|
||||
A extends JsonValue,
|
||||
O extends OutputState,
|
||||
H extends HookPoints,
|
||||
> = Omit<CoreTaskKind<I, C, R, F, A, O, H>, typeof taskKindBrand>;
|
||||
|
||||
export type TaskKindFactory<
|
||||
I extends JsonValue,
|
||||
|
|
@ -189,8 +225,10 @@ export type TaskKindFactory<
|
|||
R extends JsonValue,
|
||||
F extends JsonValue,
|
||||
A extends JsonValue,
|
||||
O extends object,
|
||||
> = KindFactory<TaskKind<I, C, R, F, A, O>>;
|
||||
O extends OutputState,
|
||||
> = <H extends HookPoints, D extends TaskKindDefinition<I, C, R, F, A, O, H>>(
|
||||
definition: NoExtra<TaskKindDefinition<I, C, R, F, A, O, H>, D> & { readonly hooks?: H },
|
||||
) => D & TaskKind<I, C, R, F, A, O, H>;
|
||||
|
||||
export type CoreTaskKindFactory<
|
||||
I extends JsonValue,
|
||||
|
|
@ -198,8 +236,10 @@ export type CoreTaskKindFactory<
|
|||
R extends JsonValue,
|
||||
F extends JsonValue,
|
||||
A extends JsonValue,
|
||||
O extends object,
|
||||
> = KindFactory<CoreTaskKind<I, C, R, F, A, O>>;
|
||||
O extends OutputState,
|
||||
> = <H extends HookPoints, D extends CoreTaskKindDefinition<I, C, R, F, A, O, H>>(
|
||||
definition: NoExtra<CoreTaskKindDefinition<I, C, R, F, A, O, H>, D> & { readonly hooks?: H },
|
||||
) => D & CoreTaskKind<I, C, R, F, A, O, H>;
|
||||
|
||||
export function defineTask<
|
||||
I extends JsonValue,
|
||||
|
|
@ -207,13 +247,15 @@ export function defineTask<
|
|||
R extends JsonValue,
|
||||
F extends JsonValue,
|
||||
A extends JsonValue,
|
||||
O extends object = never,
|
||||
O extends OutputState = never,
|
||||
>(): TaskKindFactory<I, C, R, F, A, O> {
|
||||
const define = (definition: KindDefinition<TaskKind<I, C, R, F, A, O>>) =>
|
||||
const define = <H extends HookPoints, D extends TaskKindDefinition<I, C, R, F, A, O, H>>(
|
||||
definition: NoExtra<TaskKindDefinition<I, C, R, F, A, O, H>, D> & { readonly hooks?: H },
|
||||
): D & TaskKind<I, C, R, F, A, O, H> =>
|
||||
Object.freeze({
|
||||
...definition,
|
||||
[taskKindBrand]: "ordinary" as const,
|
||||
});
|
||||
}) as D & TaskKind<I, C, R, F, A, O, H>;
|
||||
return define as TaskKindFactory<I, C, R, F, A, O>;
|
||||
}
|
||||
|
||||
|
|
@ -224,24 +266,36 @@ export function defineCoreTask<
|
|||
R extends JsonValue,
|
||||
F extends JsonValue,
|
||||
A extends JsonValue,
|
||||
O extends object = never,
|
||||
O extends OutputState = never,
|
||||
>(): CoreTaskKindFactory<I, C, R, F, A, O> {
|
||||
const define = (definition: KindDefinition<CoreTaskKind<I, C, R, F, A, O>>) =>
|
||||
const define = <H extends HookPoints, D extends CoreTaskKindDefinition<I, C, R, F, A, O, H>>(
|
||||
definition: NoExtra<CoreTaskKindDefinition<I, C, R, F, A, O, H>, D> & { readonly hooks?: H },
|
||||
): D & CoreTaskKind<I, C, R, F, A, O, H> =>
|
||||
Object.freeze({
|
||||
...definition,
|
||||
[taskKindBrand]: "core" as const,
|
||||
});
|
||||
}) as D & CoreTaskKind<I, C, R, F, A, O, H>;
|
||||
return define as CoreTaskKindFactory<I, C, R, F, A, O>;
|
||||
}
|
||||
|
||||
export type AnyTaskKind = TaskKindBase;
|
||||
export type AnyCoreTaskKind = CoreTaskKindBase;
|
||||
export type AnyDefinedTaskKind = AnyTaskKind | AnyCoreTaskKind;
|
||||
export type OrdinaryKind = {
|
||||
readonly kind: string;
|
||||
readonly [taskKindBrand]: "ordinary";
|
||||
readonly hooks?: HookPoints;
|
||||
readonly config?: ConfigBundle;
|
||||
};
|
||||
export type CoreKind = {
|
||||
readonly kind: string;
|
||||
readonly [taskKindBrand]: "core";
|
||||
readonly hooks?: HookPoints;
|
||||
readonly config?: ConfigBundle;
|
||||
};
|
||||
export type AnyDefinedKind = OrdinaryKind | CoreKind;
|
||||
|
||||
export type PayloadsOf<K> = K extends TaskKind<infer I, infer C, infer R, infer F, infer A, infer O>
|
||||
? { input: I; checkpoint: C; result: R; failure: F; aborted: A; output: O }
|
||||
: K extends CoreTaskKind<infer I, infer C, infer R, infer F, infer A, infer O>
|
||||
? { input: I; checkpoint: C; result: R; failure: F; aborted: A; output: O }
|
||||
export type PayloadsOf<K> = K extends TaskKind<infer I, infer C, infer R, infer F, infer A, infer O, infer H>
|
||||
? { input: I; checkpoint: C; result: R; failure: F; aborted: A; output: O; hooks: H }
|
||||
: K extends CoreTaskKind<infer I, infer C, infer R, infer F, infer A, infer O, infer H>
|
||||
? { input: I; checkpoint: C; result: R; failure: F; aborted: A; output: O; hooks: H }
|
||||
: never;
|
||||
export type InputOf<K> = PayloadsOf<K>["input"];
|
||||
export type CheckpointOf<K> = PayloadsOf<K>["checkpoint"];
|
||||
|
|
@ -249,9 +303,10 @@ export type ResultOf<K> = PayloadsOf<K>["result"];
|
|||
export type FailureOf<K> = PayloadsOf<K>["failure"];
|
||||
export type AbortedOf<K> = PayloadsOf<K>["aborted"];
|
||||
export type OutputOf<K> = PayloadsOf<K>["output"];
|
||||
type WithoutOutput<T> = T extends unknown ? Omit<T, "output"> : never;
|
||||
export type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
|
||||
|
||||
export type TaskOf<K extends AnyDefinedTaskKind> = WithoutOutput<
|
||||
Task<InputOf<K>, CheckpointOf<K>, ResultOf<K>, FailureOf<K>, AbortedOf<K>>
|
||||
export type TaskOf<K> = DistributiveOmit<
|
||||
Task<InputOf<K>, CheckpointOf<K>, ResultOf<K>, FailureOf<K>, AbortedOf<K>>,
|
||||
"output"
|
||||
> &
|
||||
TaskOutputField<OutputOf<K>>;
|
||||
|
|
|
|||
|
|
@ -1,22 +1,39 @@
|
|||
import { TODO_CONTEXT } from "@earendil-works/chord/context";
|
||||
import { TODO_CONTEXT, withCancel } from "@earendil-works/chord/context";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { defineInternalList, defineInternalValue } from "../../../src/harness/pico/addresses.ts";
|
||||
import {
|
||||
conversationList,
|
||||
conversationValue,
|
||||
defineList,
|
||||
defineValue,
|
||||
InvalidHistoryPosition,
|
||||
type JsonValue,
|
||||
MemoryStorage,
|
||||
ReadAfterWrite,
|
||||
OutputRetired,
|
||||
ScratchRetired,
|
||||
Session,
|
||||
SharedOutputRetired,
|
||||
sessionValue,
|
||||
type Task,
|
||||
} from "../../../src/harness/pico/index.ts";
|
||||
import {
|
||||
Closed,
|
||||
Faulted,
|
||||
NestedLineOperation,
|
||||
ReadAfterWrite,
|
||||
Session,
|
||||
type TaskCreate,
|
||||
type Transaction,
|
||||
} from "../../../src/harness/pico/index.ts";
|
||||
} from "../../../src/harness/pico/session.ts";
|
||||
|
||||
const ctx = TODO_CONTEXT;
|
||||
|
||||
function deferred(): { readonly promise: Promise<void>; readonly resolve: () => void } {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((settled) => {
|
||||
resolve = settled;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function taskSpec(
|
||||
conversationId: number,
|
||||
extra: Partial<Pick<TaskCreate, "kind" | "output" | "background">> = {},
|
||||
|
|
@ -44,13 +61,13 @@ async function setTask(session: Session, id: number, update: (task: LiveTask) =>
|
|||
|
||||
describe("Pico Session over MemoryStorage", () => {
|
||||
it("separates IDs from sequences and burns IDs from failed callbacks", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const storage = MemoryStorage.create();
|
||||
expect(await storage.commit([{ type: "conversation.create", conversation: { id: 10 } }], ctx)).toEqual([1]);
|
||||
const state = defineValue<string>({ type: "session" }, "test.state");
|
||||
expect(await storage.commit([{ type: "value.set", address: state, value: "set" }], ctx)).toEqual([2]);
|
||||
expect(await new Session(storage).commit((tx) => tx.createConversation({}), ctx)).toBe(11);
|
||||
|
||||
const sessionStorage = new MemoryStorage();
|
||||
const sessionStorage = MemoryStorage.create();
|
||||
const commit = vi.spyOn(sessionStorage, "commit");
|
||||
const session = new Session(sessionStorage);
|
||||
await expect(
|
||||
|
|
@ -71,14 +88,14 @@ describe("Pico Session over MemoryStorage", () => {
|
|||
}, ctx),
|
||||
).rejects.toBeInstanceOf(ReadAfterWrite);
|
||||
expect(await session.commit((tx) => tx.createConversation({}), ctx)).toBe(4);
|
||||
expect(
|
||||
await session.commit(async (tx) => {
|
||||
await expect(
|
||||
session.commit(async (tx) => {
|
||||
const read = tx.getConversation(1);
|
||||
expect(() => tx.createConversation({})).toThrow("Transaction writes must await reads");
|
||||
await read;
|
||||
return tx.createConversation({});
|
||||
}, ctx),
|
||||
).toBe(5);
|
||||
).rejects.toThrow("Transaction writes must await reads");
|
||||
expect(await session.commit((tx) => tx.createConversation({}), ctx)).toBe(5);
|
||||
|
||||
let leaked!: Transaction;
|
||||
await session.commit((tx) => {
|
||||
|
|
@ -97,24 +114,248 @@ describe("Pico Session over MemoryStorage", () => {
|
|||
expect(commit).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it("leaves state and sequence unchanged when persistence rejects", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const commit = vi.spyOn(storage, "commit").mockRejectedValueOnce(new Error("persistence failed"));
|
||||
await expect(new Session(storage).commit((tx) => tx.createConversation({}), ctx)).rejects.toThrow(
|
||||
"persistence failed",
|
||||
);
|
||||
it("serializes direct reads and commits in invocation order", async () => {
|
||||
const session = new Session(MemoryStorage.create());
|
||||
const entered = deferred();
|
||||
const release = deferred();
|
||||
const holding = session.commit(async () => {
|
||||
entered.resolve();
|
||||
await release.promise;
|
||||
}, ctx);
|
||||
await entered.promise;
|
||||
|
||||
let firstReadSettled = false;
|
||||
const firstRead = session.getConversations([1], ctx).then((conversations) => {
|
||||
firstReadSettled = true;
|
||||
return conversations;
|
||||
});
|
||||
const created = session.commit((tx) => tx.createConversation({}), ctx);
|
||||
const secondRead = session.getConversations([1], ctx);
|
||||
await Promise.resolve();
|
||||
expect(firstReadSettled).toBe(false);
|
||||
|
||||
release.resolve();
|
||||
await holding;
|
||||
expect(await firstRead).toEqual(new Map());
|
||||
expect(await created).toBe(1);
|
||||
expect((await secondRead).has(1)).toBe(true);
|
||||
});
|
||||
|
||||
it("checks commit cancellation before queueing and before invoking its callback", async () => {
|
||||
const storage = MemoryStorage.create();
|
||||
const commit = vi.spyOn(storage, "commit");
|
||||
const session = new Session(storage);
|
||||
const entered = deferred();
|
||||
const release = deferred();
|
||||
const holding = session.commit(async () => {
|
||||
entered.resolve();
|
||||
await release.promise;
|
||||
}, ctx);
|
||||
await entered.promise;
|
||||
|
||||
const beforeEntry = withCancel(ctx);
|
||||
const beforeEntryError = new Error("cancelled before entry");
|
||||
beforeEntry.cancel(beforeEntryError);
|
||||
let beforeEntryCallbackRan = false;
|
||||
await expect(
|
||||
session.commit(() => {
|
||||
beforeEntryCallbackRan = true;
|
||||
}, beforeEntry.context),
|
||||
).rejects.toBe(beforeEntryError);
|
||||
expect(beforeEntryCallbackRan).toBe(false);
|
||||
|
||||
const beforeCallback = withCancel(ctx);
|
||||
const beforeCallbackError = new Error("cancelled before callback");
|
||||
let beforeCallbackRan = false;
|
||||
const queued = session.commit(() => {
|
||||
beforeCallbackRan = true;
|
||||
}, beforeCallback.context);
|
||||
beforeCallback.cancel(beforeCallbackError);
|
||||
release.resolve();
|
||||
await holding;
|
||||
await expect(queued).rejects.toBe(beforeCallbackError);
|
||||
expect(beforeCallbackRan).toBe(false);
|
||||
expect(commit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses a non-cancellable context for admitted persistence", async () => {
|
||||
const storage = MemoryStorage.create();
|
||||
const commit = vi.spyOn(storage, "commit");
|
||||
const read = vi.spyOn(storage, "getConversations");
|
||||
const session = new Session(storage);
|
||||
const caller = withCancel(ctx);
|
||||
const callerError = new Error("cancelled after callback admission");
|
||||
expect(
|
||||
await session.commit((tx) => {
|
||||
const id = tx.createConversation({});
|
||||
caller.cancel(callerError);
|
||||
return id;
|
||||
}, caller.context),
|
||||
).toBe(1);
|
||||
expect(commit).toHaveBeenCalledTimes(1);
|
||||
expect(commit.mock.calls[0]![1].abortSignal).toBeUndefined();
|
||||
expect(caller.context.abortSignal?.reason).toBe(callerError);
|
||||
expect((await session.getConversations([1], ctx)).has(1)).toBe(true);
|
||||
expect(read.mock.calls[0]![1]).toBe(ctx);
|
||||
});
|
||||
|
||||
it("rejects nested Session line entry immediately", async () => {
|
||||
const session = new Session(MemoryStorage.create());
|
||||
const otherSession = new Session(MemoryStorage.create());
|
||||
expect(
|
||||
await session.commit(async (tx) => {
|
||||
await expect(session.getConversations([], ctx)).rejects.toBeInstanceOf(NestedLineOperation);
|
||||
await expect(session.commit(() => undefined, ctx)).rejects.toBeInstanceOf(NestedLineOperation);
|
||||
await expect(session.close(ctx)).rejects.toBeInstanceOf(NestedLineOperation);
|
||||
await expect(otherSession.getConversations([], ctx)).rejects.toBeInstanceOf(NestedLineOperation);
|
||||
return tx.createConversation({});
|
||||
}, ctx),
|
||||
).toBe(1);
|
||||
expect((await session.getConversations([1], ctx)).has(1)).toBe(true);
|
||||
await otherSession.close(ctx);
|
||||
});
|
||||
|
||||
it("rejects a transaction when a caught ReadAfterWrite poisoned it", async () => {
|
||||
const storage = MemoryStorage.create();
|
||||
const commit = vi.spyOn(storage, "commit");
|
||||
const session = new Session(storage);
|
||||
await expect(
|
||||
session.commit(async (tx) => {
|
||||
tx.createConversation({});
|
||||
try {
|
||||
await tx.getConversation(1);
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ReadAfterWrite);
|
||||
}
|
||||
}, ctx),
|
||||
).rejects.toBeInstanceOf(ReadAfterWrite);
|
||||
expect(commit).not.toHaveBeenCalled();
|
||||
expect(await session.commit((tx) => tx.createConversation({}), ctx)).toBe(2);
|
||||
});
|
||||
|
||||
it("reopens from the committed-ID high-water and reuses only uncommitted IDs", async () => {
|
||||
const storage = MemoryStorage.create();
|
||||
expect(storage.nextId()).toBe(1);
|
||||
await storage.close(ctx);
|
||||
await storage.close(ctx);
|
||||
expect(storage.nextId()).toBe(1);
|
||||
const session = new Session(storage);
|
||||
|
||||
expect(await session.commit((tx) => tx.createConversation({}), ctx)).toBe(2);
|
||||
expect(storage.nextId()).toBe(3);
|
||||
await session.close(ctx);
|
||||
expect(storage.nextId()).toBe(3);
|
||||
|
||||
const list = defineList<string>({ type: "session" }, "test.high-water");
|
||||
expect(
|
||||
await storage.commit(
|
||||
[
|
||||
{ type: "list.append", address: list, element: { id: 20, value: "retired" } },
|
||||
{ type: "list.clear", address: list },
|
||||
],
|
||||
ctx,
|
||||
),
|
||||
).toEqual([2, 3]);
|
||||
await storage.close(ctx);
|
||||
expect(storage.nextId()).toBe(21);
|
||||
});
|
||||
|
||||
it("faults and closes the Session after an uncertain persistence failure", async () => {
|
||||
const storage = MemoryStorage.create();
|
||||
const commit = vi.spyOn(storage, "commit").mockRejectedValueOnce(new Error("persistence failed"));
|
||||
const close = vi.spyOn(storage, "close");
|
||||
const session = new Session(storage);
|
||||
const caller = withCancel(ctx);
|
||||
await expect(
|
||||
session.commit((tx) => {
|
||||
const id = tx.createConversation({});
|
||||
caller.cancel(new Error("cancelled during persistence"));
|
||||
return id;
|
||||
}, caller.context),
|
||||
).rejects.toThrow("persistence failed");
|
||||
expect(commit).toHaveBeenCalledTimes(1);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(close.mock.calls[0]![0].abortSignal).toBeUndefined();
|
||||
await expect(session.getConversations([1], ctx)).rejects.toBeInstanceOf(Faulted);
|
||||
await expect(session.commit((tx) => tx.createConversation({}), ctx)).rejects.toBeInstanceOf(Faulted);
|
||||
await session.close(ctx);
|
||||
await session.close(ctx);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
const recovered = new Session(storage);
|
||||
await recovered.close(ctx);
|
||||
expect(close).toHaveBeenCalledTimes(2);
|
||||
expect(await storage.getConversations([1], ctx)).toEqual(new Map());
|
||||
commit.mockRestore();
|
||||
|
||||
const state = defineValue<string>({ type: "session" }, "test.after-failure");
|
||||
expect(await storage.commit([{ type: "value.set", address: state, value: "stored" }], ctx)).toEqual([1]);
|
||||
expect(await storage.getValue(state, undefined, ctx)).toBe("stored");
|
||||
expect(storage.nextId()).toBe(2);
|
||||
expect(storage.nextId()).toBe(1);
|
||||
});
|
||||
|
||||
it("makes operations queued behind an uncertain commit observe Faulted", async () => {
|
||||
const storage = MemoryStorage.create();
|
||||
const enteredCommit = deferred();
|
||||
const rejectCommit = deferred();
|
||||
vi.spyOn(storage, "commit").mockImplementationOnce(async () => {
|
||||
enteredCommit.resolve();
|
||||
await rejectCommit.promise;
|
||||
throw new Error("uncertain");
|
||||
});
|
||||
const session = new Session(storage);
|
||||
const failing = session.commit((tx) => tx.createConversation({}), ctx);
|
||||
await enteredCommit.promise;
|
||||
|
||||
let laterCallbackRan = false;
|
||||
const queuedRead = session.scanConversations({ limit: 1 }, ctx);
|
||||
const queuedCommit = session.commit(() => {
|
||||
laterCallbackRan = true;
|
||||
}, ctx);
|
||||
rejectCommit.resolve();
|
||||
|
||||
await expect(failing).rejects.toThrow("uncertain");
|
||||
await expect(queuedRead).rejects.toBeInstanceOf(Faulted);
|
||||
await expect(queuedCommit).rejects.toBeInstanceOf(Faulted);
|
||||
expect(laterCallbackRan).toBe(false);
|
||||
});
|
||||
|
||||
it("enforces exclusive Storage ownership until Session close succeeds", async () => {
|
||||
const storage = MemoryStorage.create();
|
||||
const first = new Session(storage);
|
||||
expect(() => new Session(storage)).toThrow("already owned");
|
||||
await first.close(ctx);
|
||||
const reopened = new Session(storage);
|
||||
await reopened.close(ctx);
|
||||
});
|
||||
|
||||
it("keeps Storage owned when close fails", async () => {
|
||||
const storage = MemoryStorage.create();
|
||||
const close = vi.spyOn(storage, "close").mockRejectedValueOnce(new Error("close failed"));
|
||||
const session = new Session(storage);
|
||||
await expect(session.close(ctx)).rejects.toThrow("close failed");
|
||||
await expect(session.close(ctx)).rejects.toThrow("close failed");
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(() => new Session(storage)).toThrow("already owned");
|
||||
});
|
||||
|
||||
it("closes once and rejects later low-level Session operations", async () => {
|
||||
const storage = MemoryStorage.create();
|
||||
const close = vi.spyOn(storage, "close");
|
||||
const session = new Session(storage);
|
||||
const caller = withCancel(ctx);
|
||||
caller.cancel(new Error("close caller cancelled"));
|
||||
const first = session.close(caller.context);
|
||||
const second = session.close(ctx);
|
||||
expect(second).toBe(first);
|
||||
await first;
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(close.mock.calls[0]![0].abortSignal).toBeUndefined();
|
||||
await expect(session.getConversations([], ctx)).rejects.toBeInstanceOf(Closed);
|
||||
await expect(session.commit(() => undefined, ctx)).rejects.toBeInstanceOf(Closed);
|
||||
});
|
||||
|
||||
it("uses write sequences for historical state inside one atomic storage batch", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const storage = MemoryStorage.create();
|
||||
const conversation = storage.nextId();
|
||||
const entry = storage.nextId();
|
||||
const beforeElement = storage.nextId();
|
||||
|
|
@ -146,8 +387,143 @@ describe("Pico Session over MemoryStorage", () => {
|
|||
expect((await storage.readList(list, undefined, ctx)).map((item) => item.value)).toEqual(["before", "after"]);
|
||||
});
|
||||
|
||||
it("rejects rewindable state writes after an entry in a transaction", async () => {
|
||||
const storage = MemoryStorage.create();
|
||||
const session = new Session(storage);
|
||||
const value = defineValue<string>({ type: "conversation", conversationId: 1 }, "test.order", {
|
||||
rewind: true,
|
||||
});
|
||||
await expect(
|
||||
session.commit((tx) => {
|
||||
tx.createConversation({});
|
||||
tx.entry({ conversationId: 1, kind: "test.entry" });
|
||||
try {
|
||||
tx.value(value).set("too late");
|
||||
} catch (error) {
|
||||
expect(error).toHaveProperty("message", "Rewindable state writes must precede entry appends");
|
||||
}
|
||||
}, ctx),
|
||||
).rejects.toThrow("must precede");
|
||||
expect(await storage.getConversations([1], ctx)).toEqual(new Map());
|
||||
expect(await session.commit((tx) => tx.createConversation({}), ctx)).toBe(3);
|
||||
});
|
||||
|
||||
it("validates list removals against their logical lineage", async () => {
|
||||
const storage = MemoryStorage.create();
|
||||
const rootList = defineList<string>({ type: "conversation", conversationId: 1 }, "test.lineage", {
|
||||
rewind: true,
|
||||
});
|
||||
await storage.commit(
|
||||
[
|
||||
{
|
||||
type: "conversation.create",
|
||||
conversation: {
|
||||
id: 1,
|
||||
sectionSeed: [{ key: "identity", action: "set", value: "test", rendered: "test" }],
|
||||
},
|
||||
},
|
||||
{ type: "list.append", address: rootList, element: { id: 2, value: "inherited" } },
|
||||
{ type: "entry.append", entry: { id: 3, conversationId: 1, kind: "test.fork-point" } },
|
||||
{ type: "conversation.create", conversation: { id: 4, parent: { conversationId: 1, at: 3 } } },
|
||||
{ type: "conversation.create", conversation: { id: 5 } },
|
||||
],
|
||||
ctx,
|
||||
);
|
||||
expect((await storage.getConversations([1], ctx)).get(1)).toEqual({ id: 1 });
|
||||
expect((await storage.scanConversations({ limit: 10 }, ctx)).items[0]).toEqual({ id: 1 });
|
||||
const childList = defineList<string>({ type: "conversation", conversationId: 4 }, "test.lineage", {
|
||||
rewind: true,
|
||||
});
|
||||
const unrelatedList = defineList<string>({ type: "conversation", conversationId: 5 }, "test.lineage", {
|
||||
rewind: true,
|
||||
});
|
||||
const otherDefinition = defineList<string>({ type: "conversation", conversationId: 4 }, "test.other", {
|
||||
rewind: true,
|
||||
});
|
||||
const marker = defineValue<string>({ type: "session" }, "test.atomic-remove");
|
||||
expect(
|
||||
await storage.commit(
|
||||
[{ type: "list.append", address: rootList, element: { id: 6, value: "after fork" } }],
|
||||
ctx,
|
||||
),
|
||||
).toEqual([6]);
|
||||
await expect(
|
||||
storage.commit(
|
||||
[
|
||||
{ type: "value.set", address: marker, value: "must not land" },
|
||||
{ type: "list.remove", address: childList, elementId: 6 },
|
||||
],
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow("does not belong");
|
||||
expect(await storage.getValue(marker, undefined, ctx)).toBeUndefined();
|
||||
|
||||
expect(await storage.commit([{ type: "list.remove", address: childList, elementId: 2 }], ctx)).toEqual([7]);
|
||||
expect(await storage.commit([{ type: "list.remove", address: childList, elementId: 2 }], ctx)).toEqual([8]);
|
||||
expect((await storage.readList(rootList, undefined, ctx)).map(({ id }) => id)).toEqual([2, 6]);
|
||||
expect(await storage.readList(childList, undefined, ctx)).toEqual([]);
|
||||
await expect(
|
||||
storage.commit([{ type: "list.remove", address: unrelatedList, elementId: 2 }], ctx),
|
||||
).rejects.toThrow("does not belong");
|
||||
await expect(
|
||||
storage.commit([{ type: "list.remove", address: otherDefinition, elementId: 2 }], ctx),
|
||||
).rejects.toThrow("does not belong");
|
||||
expect(await storage.commit([{ type: "list.clear", address: childList }], ctx)).toEqual([9]);
|
||||
});
|
||||
|
||||
it("validates inherited list removal against earlier writes in the same batch", async () => {
|
||||
const storage = MemoryStorage.create();
|
||||
const session = new Session(storage);
|
||||
const parentList = defineList<string>({ type: "conversation", conversationId: 1 }, "test.same-batch-lineage", {
|
||||
rewind: true,
|
||||
});
|
||||
const seeded = await session.commit((tx) => {
|
||||
const parent = tx.createConversation({});
|
||||
const element = tx.list(parentList).append("persisted");
|
||||
const at = tx.entry({ conversationId: parent, kind: "test.fork-point" });
|
||||
return { parent, element, at };
|
||||
}, ctx);
|
||||
|
||||
const child = await session.commit((tx) => {
|
||||
const id = tx.createConversation({ parent: { conversationId: seeded.parent, at: seeded.at } });
|
||||
const childList = defineList<string>({ type: "conversation", conversationId: id }, "test.same-batch-lineage", {
|
||||
rewind: true,
|
||||
});
|
||||
tx.list(childList).remove(seeded.element);
|
||||
return id;
|
||||
}, ctx);
|
||||
const childList = defineList<string>({ type: "conversation", conversationId: child }, "test.same-batch-lineage", {
|
||||
rewind: true,
|
||||
});
|
||||
expect(await storage.readList(childList, undefined, ctx)).toEqual([]);
|
||||
expect((await storage.readList(parentList, undefined, ctx)).map(({ id }) => id)).toEqual([seeded.element]);
|
||||
|
||||
const prospectiveChildList = defineList<string>(
|
||||
{ type: "conversation", conversationId: 22 },
|
||||
"test.same-batch-lineage",
|
||||
{ rewind: true },
|
||||
);
|
||||
expect(
|
||||
await storage.commit(
|
||||
[
|
||||
{ type: "list.append", address: parentList, element: { id: 20, value: "prospective" } },
|
||||
{ type: "entry.append", entry: { id: 21, conversationId: seeded.parent, kind: "test.next-fork" } },
|
||||
{
|
||||
type: "conversation.create",
|
||||
conversation: { id: 22, parent: { conversationId: seeded.parent, at: 21 } },
|
||||
},
|
||||
{ type: "list.remove", address: prospectiveChildList, elementId: 20 },
|
||||
],
|
||||
ctx,
|
||||
),
|
||||
).toEqual([6, 7, 8, 9]);
|
||||
expect((await storage.readList(prospectiveChildList, undefined, ctx)).map(({ id }) => id)).toEqual([
|
||||
seeded.element,
|
||||
]);
|
||||
});
|
||||
|
||||
it("pages conversations and fork-visible entries", async () => {
|
||||
const session = new Session(new MemoryStorage());
|
||||
const session = new Session(MemoryStorage.create());
|
||||
const ids = await session.commit((tx) => {
|
||||
const root = tx.createConversation({});
|
||||
const first = tx.entry({ conversationId: root, kind: "test.first" });
|
||||
|
|
@ -159,6 +535,12 @@ describe("Pico Session over MemoryStorage", () => {
|
|||
return { root, first, head, fork, forkEntry, late, ownedFork };
|
||||
}, ctx);
|
||||
expect(ids).toEqual({ root: 1, first: 2, head: 3, fork: 4, forkEntry: 5, late: 6, ownedFork: 7 });
|
||||
const filteredIds = await session.commit((tx) => {
|
||||
const older = tx.entry({ conversationId: ids.root, kind: "test.match" });
|
||||
tx.entry({ conversationId: ids.root, kind: "test.other" });
|
||||
const newer = tx.entry({ conversationId: ids.root, kind: "test.match" });
|
||||
return { older, newer };
|
||||
}, ctx);
|
||||
|
||||
const conversations = await session.scanConversations({ limit: 1 }, ctx);
|
||||
expect(conversations.items.map(({ id }) => id)).toEqual([1]);
|
||||
|
|
@ -189,13 +571,68 @@ describe("Pico Session over MemoryStorage", () => {
|
|||
({ id }) => id,
|
||||
),
|
||||
).toEqual([ids.head]);
|
||||
const filtered = await session.scanEntries({ conversationId: ids.root, kind: "test.match", limit: 1 }, ctx);
|
||||
expect(filtered.items.map(({ id }) => id)).toEqual([filteredIds.newer]);
|
||||
expect(filtered.next).toBe(filteredIds.newer);
|
||||
const filteredNext = await session.scanEntries(
|
||||
{ conversationId: ids.root, kind: "test.match", cursor: filtered.next, limit: 1 },
|
||||
ctx,
|
||||
);
|
||||
expect(filteredNext.items.map(({ id }) => id)).toEqual([filteredIds.older]);
|
||||
expect(filteredNext.next).toBeUndefined();
|
||||
expect((await session.getEntries([ids.first, 999_999], ctx)).has(ids.first)).toBe(true);
|
||||
expect((await session.newestHead(1, 3, ctx))?.id).toBe(3);
|
||||
expect(await session.newestHead(4, 5, ctx)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses entry sequences for scans, cutoffs, cursors, and nested fork caps", async () => {
|
||||
const storage = MemoryStorage.create();
|
||||
await storage.commit(
|
||||
[
|
||||
{ type: "conversation.create", conversation: { id: 100 } },
|
||||
{ type: "entry.append", entry: { id: 900, conversationId: 100, kind: "root.old", head: 900 } },
|
||||
{ type: "entry.append", entry: { id: 700, conversationId: 100, kind: "root.fork" } },
|
||||
{ type: "conversation.create", conversation: { id: 200, parent: { conversationId: 100, at: 700 } } },
|
||||
{ type: "entry.append", entry: { id: 950, conversationId: 100, kind: "root.late" } },
|
||||
{ type: "entry.append", entry: { id: 10, conversationId: 200, kind: "child.fork" } },
|
||||
{ type: "entry.append", entry: { id: 5, conversationId: 200, kind: "child.late" } },
|
||||
{ type: "conversation.create", conversation: { id: 300, parent: { conversationId: 200, at: 10 } } },
|
||||
{ type: "entry.append", entry: { id: 1, conversationId: 300, kind: "grand.local" } },
|
||||
{ type: "conversation.create", conversation: { id: 400 } },
|
||||
{ type: "entry.append", entry: { id: 2, conversationId: 400, kind: "unrelated" } },
|
||||
],
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect((await storage.scanEntries({ conversationId: 300, limit: 10 }, ctx)).items.map(({ id }) => id)).toEqual([
|
||||
1, 10, 700, 900,
|
||||
]);
|
||||
expect(
|
||||
(await storage.scanEntries({ conversationId: 300, through: 10, limit: 10 }, ctx)).items.map(({ id }) => id),
|
||||
).toEqual([10, 700, 900]);
|
||||
expect(
|
||||
(await storage.scanEntries({ conversationId: 300, through: 700, limit: 10 }, ctx)).items.map(({ id }) => id),
|
||||
).toEqual([700, 900]);
|
||||
const first = await storage.scanEntries({ conversationId: 300, limit: 2 }, ctx);
|
||||
expect(first.items.map(({ id }) => id)).toEqual([1, 10]);
|
||||
expect(first.next).toBe(10);
|
||||
expect(
|
||||
(await storage.scanEntries({ conversationId: 300, cursor: first.next, limit: 2 }, ctx)).items.map(
|
||||
({ id }) => id,
|
||||
),
|
||||
).toEqual([700, 900]);
|
||||
expect((await storage.newestHead(300, 10, ctx))?.id).toBe(900);
|
||||
await expect(storage.scanEntries({ conversationId: 300, through: 2, limit: 10 }, ctx)).rejects.toBeInstanceOf(
|
||||
InvalidHistoryPosition,
|
||||
);
|
||||
await expect(storage.scanEntries({ conversationId: 300, cursor: 2, limit: 10 }, ctx)).rejects.toBeInstanceOf(
|
||||
InvalidHistoryPosition,
|
||||
);
|
||||
await expect(storage.newestHead(300, 2, ctx)).rejects.toBeInstanceOf(InvalidHistoryPosition);
|
||||
});
|
||||
|
||||
it("buffers values and list elements into one commit", async () => {
|
||||
const session = new Session(new MemoryStorage());
|
||||
const session = new Session(MemoryStorage.create());
|
||||
const value = defineValue<JsonValue>({ type: "conversation", conversationId: 1 }, "test.value", {
|
||||
rewind: false,
|
||||
});
|
||||
|
|
@ -221,8 +658,63 @@ describe("Pico Session over MemoryStorage", () => {
|
|||
expect(await session.readList(list, undefined, ctx)).toEqual([]);
|
||||
});
|
||||
|
||||
it("resolves value defaults in Session and transaction reads", async () => {
|
||||
const storage = MemoryStorage.create();
|
||||
const session = new Session(storage);
|
||||
const sessionName = sessionValue<string>("test.default.session", { default: "default" });
|
||||
const conversationDefinition = conversationValue<number>("test.default.conversation", {
|
||||
rewind: true,
|
||||
default: 7,
|
||||
});
|
||||
const boundConversationValue = conversationDefinition.bind(1);
|
||||
const initial = await session.commit((tx) => {
|
||||
tx.createConversation({});
|
||||
return tx.entry({ conversationId: 1, kind: "test.initial" });
|
||||
}, ctx);
|
||||
|
||||
expect(await session.getValue(sessionName, undefined, ctx)).toBe("default");
|
||||
expect(await session.getValue(boundConversationValue, undefined, ctx)).toBe(7);
|
||||
expect(await session.getValue(boundConversationValue, initial, ctx)).toBe(7);
|
||||
expect(
|
||||
await session.commit(
|
||||
async (tx) => ({
|
||||
session: await tx.value(sessionName).get(),
|
||||
historical: await tx.value(boundConversationValue).get(initial),
|
||||
}),
|
||||
ctx,
|
||||
),
|
||||
).toEqual({ session: "default", historical: 7 });
|
||||
|
||||
const setAt = await session.commit((tx) => {
|
||||
tx.value(sessionName).set("set");
|
||||
tx.value(boundConversationValue).set(9);
|
||||
return tx.entry({ conversationId: 1, kind: "test.set" });
|
||||
}, ctx);
|
||||
const deletedAt = await session.commit((tx) => {
|
||||
tx.value(sessionName).delete();
|
||||
tx.value(boundConversationValue).delete();
|
||||
return tx.entry({ conversationId: 1, kind: "test.deleted" });
|
||||
}, ctx);
|
||||
|
||||
expect(await session.getValue(sessionName, undefined, ctx)).toBe("default");
|
||||
expect(await session.getValue(boundConversationValue, setAt, ctx)).toBe(9);
|
||||
expect(await session.getValue(boundConversationValue, deletedAt, ctx)).toBe(7);
|
||||
expect(await storage.getValue(sessionName, undefined, ctx)).toBeUndefined();
|
||||
expect(await storage.getValue(boundConversationValue, deletedAt, ctx)).toBeUndefined();
|
||||
expect(
|
||||
await session.commit(
|
||||
async (tx) => ({
|
||||
session: await tx.value(sessionName).get(),
|
||||
current: await tx.value(boundConversationValue).get(),
|
||||
historical: await tx.value(boundConversationValue).get(deletedAt),
|
||||
}),
|
||||
ctx,
|
||||
),
|
||||
).toEqual({ session: "default", current: 7, historical: 7 });
|
||||
});
|
||||
|
||||
it("rewinds values through fork caps and preserves local tombstones", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const storage = MemoryStorage.create();
|
||||
const session = new Session(storage);
|
||||
const root = await session.commit((tx) => tx.createConversation({}), ctx);
|
||||
const rootValue = defineValue<string>({ type: "conversation", conversationId: root }, "test.history", {
|
||||
|
|
@ -314,7 +806,7 @@ describe("Pico Session over MemoryStorage", () => {
|
|||
});
|
||||
const sessionValue = defineValue<string>({ type: "session" }, "test.history");
|
||||
const taskList = defineList<string>({ type: "task", taskId: 100 }, "test.history");
|
||||
const sharedValue = defineValue<string>({ type: "shared", id: 200 }, "test.history");
|
||||
const sharedValue = defineInternalValue<string>({ type: "shared", id: 200 }, "test.history");
|
||||
await expect(storage.getValue(sticky, first, ctx)).rejects.toBeInstanceOf(InvalidHistoryPosition);
|
||||
await expect(storage.getValue(sessionValue, first, ctx)).rejects.toBeInstanceOf(InvalidHistoryPosition);
|
||||
await expect(storage.readList(taskList, first, ctx)).rejects.toBeInstanceOf(InvalidHistoryPosition);
|
||||
|
|
@ -322,7 +814,7 @@ describe("Pico Session over MemoryStorage", () => {
|
|||
});
|
||||
|
||||
it("rewinds complete lists through append, remove, clear, and nested fork caps", async () => {
|
||||
const session = new Session(new MemoryStorage());
|
||||
const session = new Session(MemoryStorage.create());
|
||||
const root = await session.commit((tx) => tx.createConversation({}), ctx);
|
||||
const rootList = defineList<string>({ type: "conversation", conversationId: root }, "test.history-list", {
|
||||
rewind: true,
|
||||
|
|
@ -433,8 +925,22 @@ describe("Pico Session over MemoryStorage", () => {
|
|||
await expect(session.readList(stickyList, childRemove, ctx)).rejects.toBeInstanceOf(InvalidHistoryPosition);
|
||||
});
|
||||
|
||||
it("reserves internal namespaces and shared address construction", () => {
|
||||
const sharedAsSession = { type: "shared", id: 1 } as unknown as { readonly type: "session" };
|
||||
expect(() => defineValue<string>(sharedAsSession, "plugin.shared")).toThrow("Shared scope is internal");
|
||||
expect(() => defineList<string>(sharedAsSession, "plugin.shared")).toThrow("Shared scope is internal");
|
||||
expect(() => defineValue<string>({ type: "session" }, "pi.internal")).toThrow("reserved");
|
||||
expect(() => defineList<string>({ type: "task", taskId: 1 }, "pi.internal")).toThrow("reserved");
|
||||
expect(() => conversationValue<string>("pi.internal", { rewind: true })).toThrow("reserved");
|
||||
expect(() => conversationList<string>("pi.internal", { rewind: false })).toThrow("reserved");
|
||||
expect(defineInternalList<JsonValue>({ type: "shared", id: 1 }, "pi.output").scope).toEqual({
|
||||
type: "shared",
|
||||
id: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps scopes, kinds, optional keys, and rewind stores separate", async () => {
|
||||
const session = new Session(new MemoryStorage());
|
||||
const session = new Session(MemoryStorage.create());
|
||||
const conversation = await session.commit((tx) => tx.createConversation({}), ctx);
|
||||
const scope = { type: "conversation", conversationId: conversation } as const;
|
||||
const stickyValue = defineValue<string>(scope, "test.identity", { key: "same", rewind: false });
|
||||
|
|
@ -467,19 +973,19 @@ describe("Pico Session over MemoryStorage", () => {
|
|||
});
|
||||
|
||||
it("stores task snapshots and retires scratch and shared output", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const storage = MemoryStorage.create();
|
||||
const session = new Session(storage);
|
||||
const ids = await session.commit((tx) => {
|
||||
const conversation = tx.createConversation({});
|
||||
const producer = tx.task(taskSpec(conversation, { output: { kind: "test.output" } }));
|
||||
const output = defineList<JsonValue>({ type: "shared", id: producer }, "pi.output");
|
||||
const output = defineInternalList<JsonValue>({ type: "shared", id: producer }, "pi.output");
|
||||
tx.list(output).append([["r", { text: "" }]]);
|
||||
const consumer = tx.task(taskSpec(conversation, { output: { id: producer, kind: "test.output" } }));
|
||||
return { conversation, producer, consumer };
|
||||
}, ctx);
|
||||
expect(ids).toEqual({ conversation: 1, producer: 2, consumer: 4 });
|
||||
|
||||
const output = defineList<JsonValue>({ type: "shared", id: ids.producer }, "pi.output");
|
||||
const output = defineInternalList<JsonValue>({ type: "shared", id: ids.producer }, "pi.output");
|
||||
const scratch = defineValue<JsonValue>({ type: "task", taskId: ids.producer }, "test.scratch");
|
||||
await session.commit((tx) => {
|
||||
tx.value(scratch).set({ n: 1 });
|
||||
|
|
@ -495,7 +1001,7 @@ describe("Pico Session over MemoryStorage", () => {
|
|||
outcome: { status: "completed", result: null },
|
||||
}));
|
||||
await expect(session.getValue(scratch, undefined, ctx)).rejects.toBeInstanceOf(ScratchRetired);
|
||||
expect(await storage.getValue(scratch, undefined, ctx)).toBeUndefined();
|
||||
await expect(storage.getValue(scratch, undefined, ctx)).rejects.toBeInstanceOf(ScratchRetired);
|
||||
|
||||
await setTask(session, ids.consumer, (task) => ({ ...task, status: "running" }));
|
||||
const replacement = await session.commit(async (tx) => {
|
||||
|
|
@ -511,12 +1017,12 @@ describe("Pico Session over MemoryStorage", () => {
|
|||
status: "terminal",
|
||||
outcome: { status: "completed", result: null },
|
||||
}));
|
||||
await expect(session.readList(output, undefined, ctx)).rejects.toBeInstanceOf(SharedOutputRetired);
|
||||
expect(await storage.readList(output, undefined, ctx)).toEqual([]);
|
||||
await expect(session.readList(output, undefined, ctx)).rejects.toBeInstanceOf(OutputRetired);
|
||||
await expect(storage.readList(output, undefined, ctx)).rejects.toBeInstanceOf(OutputRetired);
|
||||
});
|
||||
|
||||
it("filters and pages tasks", async () => {
|
||||
const session = new Session(new MemoryStorage());
|
||||
const session = new Session(MemoryStorage.create());
|
||||
const ids = await session.commit((tx) => {
|
||||
const conversation = tx.createConversation({});
|
||||
const otherConversation = tx.createConversation({});
|
||||
|
|
|
|||
|
|
@ -1,44 +1,107 @@
|
|||
import type { Context } from "@earendil-works/chord";
|
||||
import type {
|
||||
AbortClosure,
|
||||
AbortedOf,
|
||||
AbortTaskRuntime,
|
||||
AssistantMessage,
|
||||
DeferredHandle,
|
||||
Message,
|
||||
RetryPolicy,
|
||||
ToolCall,
|
||||
ToolResultMessage,
|
||||
Usage,
|
||||
UserMessage,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { defineInternalList, internalConversationValue } from "../../../src/harness/pico/addresses.ts";
|
||||
import type {
|
||||
Acceptance,
|
||||
AssistantEntryData,
|
||||
BaseTaskTx,
|
||||
CheckpointOf,
|
||||
CoreAbortClosure,
|
||||
CoreAbortTaskRuntime,
|
||||
CoreTaskRuntime,
|
||||
CoreTaskTx,
|
||||
CoreTerminalClosure,
|
||||
BoundTxState,
|
||||
ConversationTx,
|
||||
Entry,
|
||||
EntryBase,
|
||||
EntryData,
|
||||
EntryInput,
|
||||
ExactJsonInput,
|
||||
FailureOf,
|
||||
InputOf,
|
||||
GenerationOutput,
|
||||
HookHandler,
|
||||
HookPoint,
|
||||
HookResult,
|
||||
HostTx,
|
||||
InputOutcome,
|
||||
JsonObject,
|
||||
JsonValue,
|
||||
List,
|
||||
OutputOf,
|
||||
OutputState,
|
||||
PayloadsOf,
|
||||
PublicScope,
|
||||
QueuedInput,
|
||||
ReadonlyTaskOutput,
|
||||
ResultOf,
|
||||
ResolvedValue,
|
||||
RunningTask,
|
||||
ScratchReader,
|
||||
SectionRecord,
|
||||
Stored,
|
||||
StoredEntryDraft,
|
||||
StoredInputResult,
|
||||
SummaryEntry,
|
||||
TaskKindBase,
|
||||
SystemEntryData,
|
||||
TaskCheckpoint,
|
||||
TaskKind,
|
||||
TaskOf,
|
||||
TaskOutcome,
|
||||
TaskOutput,
|
||||
TaskOutputRef,
|
||||
TaskRuntime,
|
||||
TerminalClosure,
|
||||
Value,
|
||||
ToolControl,
|
||||
ToolDiagnostic,
|
||||
ToolInput,
|
||||
ToolOutputState,
|
||||
ToolResultData,
|
||||
TxState,
|
||||
UsageEntryData,
|
||||
UserInput,
|
||||
} from "../../../src/harness/pico/index.ts";
|
||||
import * as Pico from "../../../src/harness/pico/index.ts";
|
||||
import {
|
||||
defineCoreTask,
|
||||
conversationList,
|
||||
conversationValue,
|
||||
defineEntry,
|
||||
defineHookPoint,
|
||||
defineList,
|
||||
defineTask,
|
||||
defineTaskOutput,
|
||||
defineValue,
|
||||
sessionValue,
|
||||
} from "../../../src/harness/pico/index.ts";
|
||||
import type { CoreAbortTaskRuntime, CoreTaskRuntime, CoreTaskTx } from "../../../src/harness/pico/runtime.ts";
|
||||
import type { DisjointBundles } from "../../../src/harness/pico/tasks.ts";
|
||||
import * as TaskDeclarations from "../../../src/harness/pico/tasks.ts";
|
||||
import { defineCoreTask } from "../../../src/harness/pico/tasks.ts";
|
||||
|
||||
// @ts-expect-error privileged core authoring is not part of the public Pico surface
|
||||
const publicDefineCoreTask = Pico.defineCoreTask;
|
||||
// @ts-expect-error the task-kind brand is private even within the tasks module
|
||||
const publicTaskKindBrand = TaskDeclarations.taskKindBrand;
|
||||
void publicDefineCoreTask;
|
||||
void publicTaskKindBrand;
|
||||
|
||||
type RemovedPublicExports = [
|
||||
// @ts-expect-error low-level Session is internal
|
||||
Pico.Session,
|
||||
// @ts-expect-error low-level transaction is internal
|
||||
Pico.Transaction,
|
||||
// @ts-expect-error low-level task creation shape is internal
|
||||
Pico.TaskCreate,
|
||||
// @ts-expect-error stored conversation metadata is internal
|
||||
Pico.StoredConversation,
|
||||
// @ts-expect-error core kind construction types are internal
|
||||
Pico.CoreTaskKind,
|
||||
// @ts-expect-error erased core kind is internal
|
||||
Pico.CoreKind,
|
||||
// @ts-expect-error collision implementation helpers are internal
|
||||
Pico.DisjointBundles,
|
||||
];
|
||||
declare const removedPublicExports: RemovedPublicExports;
|
||||
void removedPublicExports;
|
||||
|
||||
type Equal<Left, Right> = (<Type>() => Type extends Left ? 1 : 2) extends <Type>() => Type extends Right ? 1 : 2
|
||||
? (<Type>() => Type extends Right ? 1 : 2) extends <Type>() => Type extends Left ? 1 : 2
|
||||
|
|
@ -46,39 +109,62 @@ type Equal<Left, Right> = (<Type>() => Type extends Left ? 1 : 2) extends <Type>
|
|||
: false
|
||||
: false;
|
||||
type Assert<Condition extends true> = Condition;
|
||||
type HasEntry<Surface> = "entry" extends keyof Surface ? true : false;
|
||||
type HasOutput<Surface> = "output" extends keyof Surface ? true : false;
|
||||
type ExtendsJson<T extends JsonValue> = T;
|
||||
type ExtendsJsonObject<T extends JsonObject> = T;
|
||||
|
||||
type TestInput = {
|
||||
readonly prompt: string;
|
||||
readonly nested: { readonly count: number };
|
||||
};
|
||||
type TestCheckpoint = {
|
||||
readonly phase: "ready";
|
||||
readonly cursor: number;
|
||||
};
|
||||
type SameCheckpoint = {
|
||||
readonly phase: "ready";
|
||||
readonly cursor: number;
|
||||
};
|
||||
type IncompatibleCheckpoint = {
|
||||
readonly phase: "ready";
|
||||
readonly token: string;
|
||||
};
|
||||
type TestResult = { readonly answer: string };
|
||||
type TestFailure = { readonly code: "failed" };
|
||||
type TestAborted = { readonly cleaned: boolean };
|
||||
type TestOutput = { text: string; chunks: string[] };
|
||||
|
||||
type JsonAssertions = [
|
||||
Assert<TestInput extends JsonValue ? true : false>,
|
||||
Assert<readonly [string, { readonly value: number }] extends JsonValue ? true : false>,
|
||||
type StoredAssertions = [
|
||||
ExtendsJsonObject<Stored<UserMessage>>,
|
||||
ExtendsJsonObject<Stored<AssistantMessage>>,
|
||||
ExtendsJsonObject<Stored<ToolResultMessage>>,
|
||||
ExtendsJsonObject<Stored<ToolCall>>,
|
||||
ExtendsJsonObject<Stored<Usage>>,
|
||||
ExtendsJsonObject<Stored<DeferredHandle>>,
|
||||
ExtendsJsonObject<Stored<RetryPolicy>>,
|
||||
ExtendsJson<Stored<UserInput>>,
|
||||
ExtendsJson<ToolInput>,
|
||||
ExtendsJson<UsageEntryData>,
|
||||
ExtendsJson<QueuedInput>,
|
||||
ExtendsJson<StoredEntryDraft>,
|
||||
ExtendsJson<SystemEntryData>,
|
||||
ExtendsJson<AssistantEntryData>,
|
||||
ExtendsJson<ToolResultData>,
|
||||
ExtendsJson<Acceptance>,
|
||||
ExtendsJson<SectionRecord>,
|
||||
ExtendsJson<ToolControl>,
|
||||
ExtendsJson<ToolDiagnostic>,
|
||||
Assert<GenerationOutput extends OutputState ? true : false>,
|
||||
Assert<ToolOutputState extends OutputState ? true : false>,
|
||||
Assert<Date extends JsonValue ? false : true>,
|
||||
Assert<{ readonly value: undefined } extends JsonValue ? false : true>,
|
||||
];
|
||||
|
||||
const ordinaryKind = defineTask<TestInput, TestCheckpoint, TestResult, TestFailure, TestAborted>()({
|
||||
kind: "test.ordinary",
|
||||
type Input = { readonly prompt: string; readonly nested: { readonly count: number } };
|
||||
type Checkpoint = { readonly phase: "ready"; readonly cursor: number };
|
||||
type Result = { readonly answer: string };
|
||||
type Failure = { readonly code: "failed" };
|
||||
type Aborted = { readonly cleaned: boolean };
|
||||
type Progress = { text: string; chunks: string[] };
|
||||
|
||||
const pluginEnabled = conversationValue<boolean>("plugin.enabled", { rewind: false, default: false });
|
||||
const collected = defineHookPoint<{ readonly value: string }, { readonly seen: true }>({
|
||||
fold: "collect",
|
||||
onThrow: "skip",
|
||||
});
|
||||
const first = defineHookPoint<{ readonly value: string }, { readonly accepted: true }>({
|
||||
fold: "first",
|
||||
onThrow: "skip",
|
||||
});
|
||||
const beforeRun = defineHookPoint<{ readonly prompt: string }, { readonly prompt?: string }>({
|
||||
fold: "chain",
|
||||
onThrow: "skip",
|
||||
});
|
||||
const noReturnHandler: HookHandler<typeof beforeRun> = async () => {};
|
||||
void noReturnHandler;
|
||||
|
||||
const ordinaryKind = defineTask<Input, Checkpoint, Result, Failure, Aborted>()({
|
||||
kind: "plugin.ordinary",
|
||||
config: { enabled: pluginEnabled },
|
||||
hooks: { beforeRun },
|
||||
execute() {
|
||||
return Promise.resolve(() => ({ status: "completed", result: { answer: "done" } }));
|
||||
},
|
||||
|
|
@ -90,7 +176,7 @@ const ordinaryKind = defineTask<TestInput, TestCheckpoint, TestResult, TestFailu
|
|||
},
|
||||
});
|
||||
|
||||
const coreKind = defineCoreTask<TestInput, TestCheckpoint, TestResult, TestFailure, TestAborted>()({
|
||||
const coreKind = defineCoreTask<Input, Checkpoint, Result, Failure, Aborted>()({
|
||||
kind: "pi.test_core",
|
||||
execute() {
|
||||
return Promise.resolve(() => ({ status: "completed", result: { answer: "done" } }));
|
||||
|
|
@ -103,8 +189,10 @@ const coreKind = defineCoreTask<TestInput, TestCheckpoint, TestResult, TestFailu
|
|||
},
|
||||
});
|
||||
|
||||
const sameCheckpointKind = defineTask<TestInput, SameCheckpoint, TestResult, TestFailure, TestAborted>()({
|
||||
kind: "test.same_checkpoint",
|
||||
const progressOutput = defineTaskOutput<Progress>("plugin.progress");
|
||||
const outputKind = defineTask<Input, Checkpoint, Result, Failure, Aborted, Progress>()({
|
||||
kind: "plugin.output",
|
||||
output: { kind: progressOutput, initial: (input) => ({ text: input.prompt, chunks: [] }) },
|
||||
execute() {
|
||||
return Promise.resolve(() => ({ status: "completed", result: { answer: "done" } }));
|
||||
},
|
||||
|
|
@ -116,288 +204,342 @@ const sameCheckpointKind = defineTask<TestInput, SameCheckpoint, TestResult, Tes
|
|||
},
|
||||
});
|
||||
|
||||
const testOutputKind = defineTaskOutput<TestOutput>("test.output");
|
||||
const outputTaskKind = defineTask<TestInput, TestCheckpoint, TestResult, TestFailure, TestAborted, TestOutput>()({
|
||||
kind: "test.with_output",
|
||||
output: {
|
||||
kind: testOutputKind,
|
||||
initial(input) {
|
||||
return { text: input.prompt, chunks: [] };
|
||||
},
|
||||
},
|
||||
execute() {
|
||||
return Promise.resolve(() => ({ status: "completed", result: { answer: "done" } }));
|
||||
},
|
||||
recover() {
|
||||
return Promise.resolve(() => ({ status: "failed", failure: { code: "failed" } }));
|
||||
},
|
||||
abort() {
|
||||
return Promise.resolve(() => ({ cleaned: true }));
|
||||
},
|
||||
});
|
||||
|
||||
const incompatibleCheckpointKind = defineTask<
|
||||
TestInput,
|
||||
IncompatibleCheckpoint,
|
||||
TestResult,
|
||||
TestFailure,
|
||||
TestAborted
|
||||
>()({
|
||||
kind: "test.incompatible_checkpoint",
|
||||
execute() {
|
||||
return Promise.resolve(() => ({ status: "completed", result: { answer: "done" } }));
|
||||
},
|
||||
recover() {
|
||||
return Promise.resolve(() => ({ status: "failed", failure: { code: "failed" } }));
|
||||
},
|
||||
abort() {
|
||||
return Promise.resolve(() => ({ cleaned: true }));
|
||||
},
|
||||
});
|
||||
|
||||
type OrdinaryRuntimeTx = Parameters<Parameters<TaskRuntime<TestInput, TestCheckpoint>["commit"]>[0]>[0];
|
||||
type CoreRuntimeTx = Parameters<Parameters<CoreTaskRuntime<TestInput, TestCheckpoint>["commit"]>[0]>[0];
|
||||
type OrdinaryAbortTx = Parameters<Parameters<AbortTaskRuntime<TestInput, TestCheckpoint>["commit"]>[0]>[0];
|
||||
type CoreRuntimeAbortTx = Parameters<Parameters<CoreAbortTaskRuntime<TestInput, TestCheckpoint>["commit"]>[0]>[0];
|
||||
type OrdinaryFinalTx = Parameters<TerminalClosure<TestInput, TestCheckpoint, TestResult, TestFailure, never>>[0];
|
||||
type CoreFinalTx = Parameters<CoreTerminalClosure<TestInput, TestCheckpoint, TestResult, TestFailure, never>>[0];
|
||||
type OrdinaryAbortFinalTx = Parameters<AbortClosure<TestInput, TestCheckpoint, TestAborted, never>>[0];
|
||||
type CoreAbortFinalTx = Parameters<CoreAbortClosure<TestInput, TestCheckpoint, TestAborted, never>>[0];
|
||||
type OutputFinalTx = Parameters<TerminalClosure<TestInput, TestCheckpoint, TestResult, TestFailure, TestOutput>>[0];
|
||||
type OutputAbortFinalTx = Parameters<AbortClosure<TestInput, TestCheckpoint, TestAborted, TestOutput>>[0];
|
||||
|
||||
type HookAssertions = [
|
||||
Assert<Equal<typeof collected, HookPoint<{ readonly value: string }, { readonly seen: true }, "collect">>>,
|
||||
Assert<Equal<typeof first, HookPoint<{ readonly value: string }, { readonly accepted: true }, "first">>>,
|
||||
Assert<Equal<typeof beforeRun, HookPoint<{ readonly prompt: string }, { readonly prompt?: string }, "chain">>>,
|
||||
Assert<
|
||||
Equal<
|
||||
HookResult<typeof collected>,
|
||||
{ readonly outputs: readonly { readonly seen: true }[]; readonly threw?: unknown }
|
||||
>
|
||||
>,
|
||||
Assert<Equal<HookResult<typeof first>, { readonly output?: { readonly accepted: true }; readonly threw?: unknown }>>,
|
||||
];
|
||||
type InferredHooks = PayloadsOf<typeof ordinaryKind>["hooks"];
|
||||
type KindAssertions = [
|
||||
Assert<"turn" extends keyof typeof ordinaryKind ? false : true>,
|
||||
Assert<"turn" extends keyof typeof coreKind ? false : true>,
|
||||
Assert<"busy" extends keyof TaskOf<typeof ordinaryKind> ? false : true>,
|
||||
Assert<Equal<InputOf<typeof ordinaryKind>, TestInput>>,
|
||||
Assert<Equal<InputOf<typeof coreKind>, TestInput>>,
|
||||
Assert<Equal<CheckpointOf<typeof ordinaryKind>, TestCheckpoint>>,
|
||||
Assert<Equal<ResultOf<typeof ordinaryKind>, TestResult>>,
|
||||
Assert<Equal<FailureOf<typeof ordinaryKind>, TestFailure>>,
|
||||
Assert<Equal<AbortedOf<typeof ordinaryKind>, TestAborted>>,
|
||||
Assert<Equal<PayloadsOf<typeof ordinaryKind>["aborted"], TestAborted>>,
|
||||
Assert<Equal<OutputOf<typeof ordinaryKind>, never>>,
|
||||
Assert<Equal<OutputOf<typeof outputTaskKind>, TestOutput>>,
|
||||
Assert<Equal<Parameters<typeof ordinaryKind.execute>[1], TaskRuntime<TestInput, TestCheckpoint>>>,
|
||||
Assert<Equal<Parameters<typeof coreKind.execute>[1], CoreTaskRuntime<TestInput, TestCheckpoint>>>,
|
||||
Assert<Equal<Parameters<typeof outputTaskKind.execute>[1]["output"], TaskOutput<TestOutput>>>,
|
||||
Assert<Equal<ReturnType<Parameters<TaskOutput<TestOutput>["mutate"]>[0]>, undefined>>,
|
||||
Assert<Equal<Parameters<typeof ordinaryKind.abort>[1], AbortTaskRuntime<TestInput, TestCheckpoint>>>,
|
||||
Assert<Equal<Parameters<typeof coreKind.abort>[1], CoreAbortTaskRuntime<TestInput, TestCheckpoint>>>,
|
||||
Assert<Equal<Parameters<typeof outputTaskKind.abort>[1]["output"], ReadonlyTaskOutput<TestOutput>>>,
|
||||
Assert<Equal<HasOutput<Parameters<typeof ordinaryKind.execute>[1]>, false>>,
|
||||
Assert<Equal<HasOutput<Parameters<typeof outputTaskKind.execute>[1]>, true>>,
|
||||
Assert<Equal<OutputFinalTx["output"], TestOutput>>,
|
||||
Assert<Equal<OutputAbortFinalTx["output"], TestOutput>>,
|
||||
Assert<Equal<HasEntry<OrdinaryRuntimeTx>, false>>,
|
||||
Assert<Equal<HasEntry<CoreRuntimeTx>, true>>,
|
||||
Assert<Equal<HasEntry<OrdinaryFinalTx>, false>>,
|
||||
Assert<Equal<HasEntry<CoreFinalTx>, true>>,
|
||||
Assert<Equal<HasEntry<OrdinaryAbortTx>, false>>,
|
||||
Assert<Equal<HasEntry<CoreRuntimeAbortTx>, true>>,
|
||||
Assert<Equal<HasEntry<OrdinaryAbortFinalTx>, false>>,
|
||||
Assert<Equal<HasEntry<CoreAbortFinalTx>, true>>,
|
||||
Assert<"sleep" extends keyof AbortTaskRuntime<TestInput, TestCheckpoint> ? false : true>,
|
||||
Assert<Equal<typeof ordinaryKind.config.enabled, typeof pluginEnabled>>,
|
||||
Assert<Equal<typeof ordinaryKind.hooks.beforeRun, typeof beforeRun>>,
|
||||
Assert<Equal<keyof InferredHooks, "beforeRun">>,
|
||||
Assert<Equal<InferredHooks["beforeRun"], typeof beforeRun>>,
|
||||
Assert<
|
||||
Equal<
|
||||
Parameters<typeof ordinaryKind.execute>[1],
|
||||
TaskRuntime<Input, Checkpoint, never, typeof ordinaryKind.hooks>
|
||||
>
|
||||
>,
|
||||
Assert<Equal<Parameters<typeof coreKind.execute>[1], CoreTaskRuntime<Input, Checkpoint>>>,
|
||||
Assert<Equal<Parameters<typeof outputKind.execute>[1]["output"], TaskOutput<Progress>>>,
|
||||
Assert<Equal<Parameters<typeof outputKind.abort>[1]["output"], ReadonlyTaskOutput<Progress>>>,
|
||||
Assert<
|
||||
Equal<
|
||||
Extract<TaskOf<typeof ordinaryKind>, { status: "terminal" }>["outcome"]["status"],
|
||||
"completed" | "failed" | "aborted" | "orphaned"
|
||||
>
|
||||
>,
|
||||
];
|
||||
|
||||
type KindOutcome = Extract<TaskOf<typeof ordinaryKind>, { readonly status: "terminal" }>["outcome"];
|
||||
type OutcomeAssertions = [
|
||||
Assert<Equal<KindOutcome, TaskOutcome<TestResult, TestFailure, TestAborted>>>,
|
||||
Assert<Equal<Extract<KindOutcome, { readonly status: "completed" }>["result"], TestResult>>,
|
||||
Assert<Equal<Extract<KindOutcome, { readonly status: "failed" }>["failure"], TestFailure>>,
|
||||
Assert<Equal<Extract<KindOutcome, { readonly status: "aborted" }>["result"], TestAborted>>,
|
||||
];
|
||||
|
||||
const summaryKind = defineEntry<SummaryEntry>("pi.summary");
|
||||
type SummaryInput = EntryInput<SummaryEntry>;
|
||||
type EntryAssertions = [
|
||||
Assert<Equal<typeof summaryKind.kind, string>>,
|
||||
Assert<Equal<SummaryInput["head"], number | "self">>,
|
||||
Assert<"id" extends keyof SummaryInput ? false : true>,
|
||||
Assert<"conversationId" extends keyof SummaryInput ? false : true>,
|
||||
Assert<"kind" extends keyof SummaryInput ? false : true>,
|
||||
Assert<"byTaskId" extends keyof SummaryInput ? false : true>,
|
||||
];
|
||||
|
||||
function verifyTaskInputs(
|
||||
tx: BaseTaskTx<TestCheckpoint>,
|
||||
outputRef: TaskOutputRef<TestOutput>,
|
||||
wrongOutputRef: TaskOutputRef<{ count: number }>,
|
||||
): void {
|
||||
tx.task(ordinaryKind, {
|
||||
input: { prompt: "hello", nested: { count: 1 } },
|
||||
});
|
||||
// @ts-expect-error ordinary task transactions cannot instantiate fixed core task kinds
|
||||
tx.task(coreKind, { input: { prompt: "core", nested: { count: 1 } } });
|
||||
tx.task(outputTaskKind, {
|
||||
input: { prompt: "own output", nested: { count: 1 } },
|
||||
});
|
||||
tx.task(outputTaskKind, {
|
||||
input: { prompt: "shared output", nested: { count: 1 } },
|
||||
output: outputRef,
|
||||
});
|
||||
// @ts-expect-error a no-output kind cannot receive an output reference
|
||||
tx.task(ordinaryKind, { input: { prompt: "hello", nested: { count: 1 } }, output: outputRef });
|
||||
tx.task(outputTaskKind, {
|
||||
input: { prompt: "wrong output", nested: { count: 1 } },
|
||||
// @ts-expect-error a shared output reference must have the kind's output type
|
||||
output: wrongOutputRef,
|
||||
});
|
||||
|
||||
const exactVariable = {
|
||||
conversationId: 1,
|
||||
input: { prompt: "hello", nested: { count: 1 } },
|
||||
background: true,
|
||||
} as const;
|
||||
tx.task(ordinaryKind, exactVariable);
|
||||
|
||||
// Top-level exactness intentionally does not recursively reject extra nested fields.
|
||||
tx.task(ordinaryKind, {
|
||||
input: { prompt: "hello", nested: { count: 1, extension: true } },
|
||||
});
|
||||
|
||||
// @ts-expect-error task input literals reject extra top-level fields
|
||||
tx.task(ordinaryKind, { input: { prompt: "hello", nested: { count: 1 }, extension: true } });
|
||||
|
||||
const extraInputVariable = { prompt: "hello", nested: { count: 1 }, extension: true };
|
||||
// @ts-expect-error task input variables reject visible extra top-level fields
|
||||
tx.task(ordinaryKind, { input: extraInputVariable });
|
||||
|
||||
const inputBase = { prompt: "hello", nested: { count: 1 } };
|
||||
// @ts-expect-error task input spreads reject visible extra top-level fields
|
||||
tx.task(ordinaryKind, { input: { ...inputBase, extension: true } });
|
||||
|
||||
const extraSpecVariable = {
|
||||
input: { prompt: "hello", nested: { count: 1 } },
|
||||
priority: 1,
|
||||
};
|
||||
// @ts-expect-error task specs reject visible extra top-level fields
|
||||
tx.task(ordinaryKind, extraSpecVariable);
|
||||
|
||||
tx.checkpoint({ phase: "ready", cursor: 1 });
|
||||
// @ts-expect-error every checkpoint requires its phase
|
||||
tx.checkpoint({ cursor: 1 });
|
||||
// @ts-expect-error checkpoint replacement requires the complete kind-specific shape
|
||||
tx.checkpoint({ phase: "ready" });
|
||||
// @ts-expect-error an incompatible checkpoint shape is rejected
|
||||
tx.checkpoint({ phase: "ready", token: "cursor" });
|
||||
}
|
||||
|
||||
function verifyOrdinaryRegistration(register: (kind: TaskKindBase) => void): void {
|
||||
register(ordinaryKind);
|
||||
// @ts-expect-error fixed core kinds cannot enter the ordinary task registry
|
||||
register(coreKind);
|
||||
}
|
||||
|
||||
function verifyCoreTaskInputs(tx: CoreTaskTx<TestCheckpoint>): void {
|
||||
tx.task(ordinaryKind, {
|
||||
input: { prompt: "ordinary child", nested: { count: 1 } },
|
||||
});
|
||||
tx.task(coreKind, {
|
||||
input: { prompt: "core child", nested: { count: 1 } },
|
||||
});
|
||||
}
|
||||
|
||||
function verifyOutputMutation(
|
||||
output: TaskOutput<TestOutput>,
|
||||
readonlyOutput: ReadonlyTaskOutput<TestOutput>,
|
||||
function verifyNoOutputRuntime(
|
||||
runtime: TaskRuntime<Input, Checkpoint>,
|
||||
abortRuntime: Parameters<typeof ordinaryKind.abort>[1],
|
||||
finalTx: Parameters<TerminalClosure<Input, Checkpoint, Result, Failure, never>>[0],
|
||||
running: RunningTask<Input, Checkpoint>,
|
||||
ctx: Context,
|
||||
): void {
|
||||
// @ts-expect-error no-output runtimes have no output property
|
||||
void runtime.output;
|
||||
// @ts-expect-error no-output abort runtimes have no output property
|
||||
void abortRuntime.output;
|
||||
// @ts-expect-error no-output final transactions have no output property
|
||||
void finalTx.output;
|
||||
// @ts-expect-error no-output running tasks have no output property
|
||||
void running.output;
|
||||
void abortRuntime.sleep(1, ctx);
|
||||
}
|
||||
|
||||
function verifyOutput(output: TaskOutput<Progress>, readonlyOutput: ReadonlyTaskOutput<Progress>, ctx: Context): void {
|
||||
void output.mutate((state) => {
|
||||
state.chunks.push("chunk");
|
||||
state.chunks.push("next");
|
||||
}, ctx);
|
||||
// @ts-expect-error task-output mutation callbacks must be synchronous
|
||||
// @ts-expect-error output mutations must be synchronous
|
||||
void output.mutate(async (state) => {
|
||||
state.chunks.push("chunk");
|
||||
state.text = "invalid";
|
||||
}, ctx);
|
||||
// @ts-expect-error fresh abort output is read-only
|
||||
// @ts-expect-error fresh abort receives read-only output
|
||||
void readonlyOutput.mutate;
|
||||
}
|
||||
|
||||
function verifyCheckpointCompatibility(
|
||||
checkpoint: CheckpointOf<typeof sameCheckpointKind>,
|
||||
incompatible: CheckpointOf<typeof incompatibleCheckpointKind>,
|
||||
): void {
|
||||
// Checkpoints are structural: an independently declared identical shape is assignable.
|
||||
const structurallyCompatible: CheckpointOf<typeof ordinaryKind> = checkpoint;
|
||||
void structurallyCompatible;
|
||||
// @ts-expect-error a differently shaped kind checkpoint is not assignable
|
||||
const structurallyIncompatible: CheckpointOf<typeof ordinaryKind> = incompatible;
|
||||
void structurallyIncompatible;
|
||||
type JobOutput = {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
droppedStdout: number;
|
||||
droppedStderr: number;
|
||||
exitCode?: number;
|
||||
occurrence: number;
|
||||
};
|
||||
type JobAssertions = [Assert<JobOutput extends OutputState ? true : false>];
|
||||
function verifyJobMutation(output: TaskOutput<JobOutput>, snapshot: Readonly<JobOutput>, ctx: Context): void {
|
||||
void output.mutate((state) => {
|
||||
state.stdout = snapshot.stdout;
|
||||
}, ctx);
|
||||
}
|
||||
|
||||
function verifyOutcomes(outcome: KindOutcome): void {
|
||||
const completed: KindOutcome = { status: "completed", result: { answer: "done" } };
|
||||
const failed: KindOutcome = { status: "failed", failure: { code: "failed" } };
|
||||
const aborted: KindOutcome = { status: "aborted", result: { cleaned: true } };
|
||||
const orphaned: KindOutcome = { status: "orphaned" };
|
||||
// @ts-expect-error completed outcomes require this kind's result payload
|
||||
const wrongCompleted: KindOutcome = { status: "completed", result: { answer: 1 } };
|
||||
// @ts-expect-error failed outcomes require this kind's failure payload
|
||||
const wrongFailure: KindOutcome = { status: "failed", failure: { code: "other" } };
|
||||
// @ts-expect-error aborted outcomes require this kind's abort payload
|
||||
const wrongAborted: KindOutcome = { status: "aborted", result: { cleaned: "yes" } };
|
||||
void outcome;
|
||||
void completed;
|
||||
void failed;
|
||||
void aborted;
|
||||
void orphaned;
|
||||
void wrongCompleted;
|
||||
void wrongFailure;
|
||||
void wrongAborted;
|
||||
type NoteEntry = EntryBase & EntryData<{ readonly text: string }>;
|
||||
type PinnedEntry = EntryBase &
|
||||
EntryData<{ readonly text: string }> & {
|
||||
readonly model: readonly [Stored<UserMessage>];
|
||||
};
|
||||
const noteKind = defineEntry<NoteEntry>("plugin.note");
|
||||
const pinnedKind = defineEntry<PinnedEntry>("plugin.pinned");
|
||||
const summaryKind = defineEntry<SummaryEntry>("pi.summary");
|
||||
|
||||
function verifyEntryInputs(tx: ConversationTx, text: string): void {
|
||||
void tx.write(noteKind, { data: { text } });
|
||||
void tx.write(pinnedKind, {
|
||||
data: { text },
|
||||
model: [{ role: "user", content: text, timestamp: 1 }],
|
||||
});
|
||||
void tx.write(summaryKind, {
|
||||
data: { through: 1 },
|
||||
model: [{ role: "user", content: text, timestamp: 1 }],
|
||||
head: "self",
|
||||
});
|
||||
// @ts-expect-error kind is supplied by the witness
|
||||
void tx.write(noteKind, { kind: "plugin.note", data: { text } });
|
||||
// @ts-expect-error id is supplied by Pico
|
||||
void tx.write(noteKind, { id: 1, data: { text } });
|
||||
}
|
||||
|
||||
type EntryAssertions = [
|
||||
Assert<NoteEntry extends Entry ? true : false>,
|
||||
Assert<PinnedEntry extends Entry ? true : false>,
|
||||
Assert<SummaryEntry extends Entry ? true : false>,
|
||||
Assert<Equal<EntryInput<SummaryEntry>["head"], number | "self">>,
|
||||
];
|
||||
|
||||
function verifyHostTaskCreation(tx: HostTx): void {
|
||||
tx.task(ordinaryKind, { conversationId: 1, input: { prompt: "host", nested: { count: 1 } } });
|
||||
// @ts-expect-error host task creation requires a conversation ID
|
||||
tx.task(ordinaryKind, { input: { prompt: "host", nested: { count: 1 } } });
|
||||
}
|
||||
|
||||
function verifyTaskCreation(
|
||||
tx: BaseTaskTx<Checkpoint>,
|
||||
coreTx: CoreTaskTx<Checkpoint>,
|
||||
outputRef: TaskOutputRef<Progress>,
|
||||
): void {
|
||||
tx.task(ordinaryKind, { input: { prompt: "literal", nested: { count: 1 } } });
|
||||
const exact = { input: { prompt: "variable", nested: { count: 1 } } };
|
||||
tx.task(ordinaryKind, exact);
|
||||
tx.task(ordinaryKind, { ...exact });
|
||||
// @ts-expect-error ordinary transactions cannot create core tasks
|
||||
tx.task(coreKind, { input: { prompt: "core", nested: { count: 1 } } });
|
||||
coreTx.task(coreKind, { input: { prompt: "core", nested: { count: 1 } } });
|
||||
|
||||
// @ts-expect-error input literals reject visible top-level extras
|
||||
tx.task(ordinaryKind, { input: { prompt: "extra", nested: { count: 1 }, extra: 1 } });
|
||||
const extraInput = { prompt: "extra", nested: { count: 1 }, extra: 1 };
|
||||
// @ts-expect-error input variables reject visible top-level extras
|
||||
tx.task(ordinaryKind, { input: extraInput });
|
||||
const inputBase = { prompt: "extra", nested: { count: 1 } };
|
||||
// @ts-expect-error input spreads reject visible top-level extras
|
||||
tx.task(ordinaryKind, { input: { ...inputBase, extra: 1 } });
|
||||
const extraSpec = { input: { prompt: "extra", nested: { count: 1 } }, extra: 1 };
|
||||
// @ts-expect-error specs reject visible extras
|
||||
tx.task(ordinaryKind, extraSpec);
|
||||
// @ts-expect-error spread specs reject visible extras
|
||||
tx.task(ordinaryKind, { ...extraSpec });
|
||||
|
||||
// Deep exactness is outside the contract.
|
||||
tx.task(ordinaryKind, { input: { prompt: "nested", nested: { count: 1, extension: true } } });
|
||||
tx.task(outputKind, { input: { prompt: "output", nested: { count: 1 } }, output: outputRef });
|
||||
// @ts-expect-error no-output kinds reject output refs
|
||||
tx.task(ordinaryKind, { input: { prompt: "output", nested: { count: 1 } }, output: outputRef });
|
||||
}
|
||||
|
||||
function verifyAddresses(): void {
|
||||
const sessionValue = defineValue<string>({ type: "session" }, "plugin.name");
|
||||
const taskList = defineList<number>({ type: "task", taskId: 2 }, "plugin.items", { key: "active" });
|
||||
const sharedList = defineList<number>({ type: "shared", id: 4 }, "plugin.shared");
|
||||
const conversationValue = defineValue<TestResult>({ type: "conversation", conversationId: 3 }, "plugin.result", {
|
||||
const rewindable = conversationValue<boolean>("plugin.plan", { rewind: true });
|
||||
const defaulted = conversationValue<boolean>("plugin.plan", { key: "defaulted", rewind: true, default: false });
|
||||
const sticky = conversationValue<string>("plugin.mode", { rewind: false });
|
||||
const events = conversationList<string>("plugin.events", { rewind: true });
|
||||
const plainSession = sessionValue<string>("plugin.plain-name");
|
||||
const defaultedSession = sessionValue<string>("plugin.name", { default: "pico" });
|
||||
const directPlainSession = defineValue<string>({ type: "session" }, "plugin.direct-session");
|
||||
const directDefaultedSession = defineValue<string>({ type: "session" }, "plugin.direct-session-default", {
|
||||
default: "session",
|
||||
});
|
||||
const directDefaultedTask = defineValue<string>({ type: "task", taskId: 1 }, "plugin.direct-task-default", {
|
||||
default: "task",
|
||||
});
|
||||
const directPlainConversation = defineValue<boolean>(
|
||||
{ type: "conversation", conversationId: 1 },
|
||||
"plugin.direct-conversation",
|
||||
{ rewind: true },
|
||||
);
|
||||
const directDefaultedConversation = defineValue<boolean>(
|
||||
{ type: "conversation", conversationId: 1 },
|
||||
"plugin.direct-conversation-default",
|
||||
{ rewind: false, default: false },
|
||||
);
|
||||
const bound = rewindable.bind(1);
|
||||
const defaultedBound = defaulted.bind(1);
|
||||
const internalDefaulted = internalConversationValue<string, true>("pi.internal-default", {
|
||||
rewind: true,
|
||||
default: "internal",
|
||||
});
|
||||
const conversationList = defineList<string>({ type: "conversation", conversationId: 3 }, "plugin.events", {
|
||||
rewind: false,
|
||||
});
|
||||
const typedSessionValue: Value<string> = sessionValue;
|
||||
const typedTaskList: List<number> = taskList;
|
||||
void typedSessionValue;
|
||||
void typedTaskList;
|
||||
void sharedList;
|
||||
void conversationValue;
|
||||
void conversationList;
|
||||
const internalDefaultedBound = internalDefaulted.bind(1);
|
||||
const boundList: List<string> = events.bind(1);
|
||||
void sticky;
|
||||
void bound;
|
||||
void defaultedBound;
|
||||
void internalDefaulted;
|
||||
void internalDefaultedBound;
|
||||
void boundList;
|
||||
void plainSession;
|
||||
void defaultedSession;
|
||||
void directPlainSession;
|
||||
void directDefaultedSession;
|
||||
void directDefaultedTask;
|
||||
void directPlainConversation;
|
||||
void directDefaultedConversation;
|
||||
|
||||
// @ts-expect-error conversation addresses explicitly select rewind behavior
|
||||
defineValue<string>({ type: "conversation", conversationId: 3 }, "plugin.value");
|
||||
type AddressAssertions = [
|
||||
Assert<Equal<(typeof rewindable)["rewind"], true>>,
|
||||
Assert<Equal<(typeof sticky)["rewind"], false>>,
|
||||
Assert<Equal<ResolvedValue<typeof plainSession>, string | undefined>>,
|
||||
Assert<Equal<ResolvedValue<typeof defaultedSession>, string>>,
|
||||
Assert<Equal<ResolvedValue<typeof directPlainSession>, string | undefined>>,
|
||||
Assert<Equal<ResolvedValue<typeof directDefaultedSession>, string>>,
|
||||
Assert<Equal<ResolvedValue<typeof directDefaultedTask>, string>>,
|
||||
Assert<Equal<ResolvedValue<typeof directPlainConversation>, boolean | undefined>>,
|
||||
Assert<Equal<ResolvedValue<typeof directDefaultedConversation>, boolean>>,
|
||||
Assert<Equal<(typeof directDefaultedSession)["default"], string>>,
|
||||
Assert<Equal<(typeof directDefaultedTask)["default"], string>>,
|
||||
Assert<Equal<(typeof directDefaultedConversation)["default"], boolean>>,
|
||||
Assert<Equal<ResolvedValue<typeof rewindable>, boolean | undefined>>,
|
||||
Assert<Equal<ResolvedValue<typeof defaulted>, boolean>>,
|
||||
Assert<Equal<ResolvedValue<typeof bound>, boolean | undefined>>,
|
||||
Assert<Equal<ResolvedValue<typeof defaultedBound>, boolean>>,
|
||||
Assert<Equal<(typeof defaultedBound)["default"], boolean>>,
|
||||
Assert<Equal<ResolvedValue<typeof internalDefaulted>, string>>,
|
||||
Assert<Equal<ResolvedValue<typeof internalDefaultedBound>, string>>,
|
||||
Assert<Equal<(typeof defaultedSession)["default"], string>>,
|
||||
Assert<Extract<PublicScope, { type: "shared" }> extends never ? true : false>,
|
||||
];
|
||||
const assertions = null as unknown as AddressAssertions;
|
||||
void assertions;
|
||||
|
||||
// @ts-expect-error public bound constructors do not accept shared scope
|
||||
defineValue<string>({ type: "shared", id: 1 }, "plugin.shared");
|
||||
// @ts-expect-error public bound constructors do not accept shared scope
|
||||
defineList<string>({ type: "shared", id: 1 }, "plugin.shared");
|
||||
// @ts-expect-error conversation addresses require an explicit rewind policy
|
||||
defineValue<string>({ type: "conversation", conversationId: 1 }, "plugin.value");
|
||||
// @ts-expect-error session addresses are sticky
|
||||
defineValue<string>({ type: "session" }, "plugin.value", { rewind: true });
|
||||
// @ts-expect-error task addresses are never rewindable
|
||||
defineList<string>({ type: "task", taskId: 2 }, "plugin.list", { rewind: true });
|
||||
// @ts-expect-error shared addresses are never rewindable
|
||||
defineList<string>({ type: "shared", id: 4 }, "plugin.shared", { rewind: true });
|
||||
// @ts-expect-error address payloads are strict JSON
|
||||
defineValue<Date>({ type: "session" }, "plugin.date");
|
||||
defineInternalList<JsonValue>({ type: "shared", id: 1 }, "pi.output");
|
||||
}
|
||||
|
||||
type ExactInputAssertion = Assert<
|
||||
Equal<ExactJsonInput<TestInput, TestInput & { readonly extension: boolean }>["extension"], never>
|
||||
>;
|
||||
function verifyResolvedTransactionReads(tx: TxState, boundTx: BoundTxState, scratch: ScratchReader): void {
|
||||
const plainSession = defineValue<string>({ type: "session" }, "plugin.tx-plain");
|
||||
const defaultedSession = defineValue<string>({ type: "session" }, "plugin.tx-default", { default: "x" });
|
||||
const defaultedTask = defineValue<number>({ type: "task", taskId: 1 }, "plugin.scratch-default", {
|
||||
default: 1,
|
||||
});
|
||||
const defaultedConversation = defineValue<boolean>(
|
||||
{ type: "conversation", conversationId: 1 },
|
||||
"plugin.tx-conversation-default",
|
||||
{ rewind: true, default: false },
|
||||
);
|
||||
const plainDefinition = conversationValue<number>("plugin.bound-plain", { rewind: true });
|
||||
const defaultedDefinition = conversationValue<number>("plugin.bound-default", { rewind: false, default: 1 });
|
||||
const plainRead = tx.value(plainSession).get();
|
||||
const defaultedRead = tx.value(defaultedSession).get();
|
||||
const defaultedConversationRead = tx.value(defaultedConversation).get();
|
||||
const plainDefinitionRead = boundTx.value(plainDefinition).get();
|
||||
const defaultedDefinitionRead = boundTx.value(defaultedDefinition).get();
|
||||
const scratchRead = scratch.value(defaultedTask).get();
|
||||
type ReadAssertions = [
|
||||
Assert<Equal<Awaited<typeof plainRead>, string | undefined>>,
|
||||
Assert<Equal<Awaited<typeof defaultedRead>, string>>,
|
||||
Assert<Equal<Awaited<typeof defaultedConversationRead>, boolean>>,
|
||||
Assert<Equal<Awaited<typeof plainDefinitionRead>, number | undefined>>,
|
||||
Assert<Equal<Awaited<typeof defaultedDefinitionRead>, number>>,
|
||||
Assert<Equal<Awaited<typeof scratchRead>, number>>,
|
||||
];
|
||||
const assertions = null as unknown as ReadAssertions;
|
||||
void assertions;
|
||||
}
|
||||
|
||||
declare const jsonAssertions: JsonAssertions;
|
||||
type FakeDuplicateConfigs = { one: { value: 1 }; two: { value: 2 } };
|
||||
type FakeReservedConfig = { one: { set: 1 }; two: Record<never, never> };
|
||||
type FakeDuplicateHooks = { one: { before: 1 }; two: { before: 2 } };
|
||||
type RealConfigs = { one: { model: 1 }; two: { retry: 2 } };
|
||||
type RealHooks = { one: { before: 1 }; two: { after: 2 } };
|
||||
type BundleAssertions = [Assert<DisjointBundles<RealConfigs, "set" | "get">>, Assert<DisjointBundles<RealHooks>>];
|
||||
type BadBundleAssertions = [
|
||||
// @ts-expect-error duplicate bundle keys are rejected by the assertion
|
||||
Assert<DisjointBundles<FakeDuplicateConfigs, "set" | "get">>,
|
||||
// @ts-expect-error reserved config keys are rejected by the assertion
|
||||
Assert<DisjointBundles<FakeReservedConfig, "set" | "get">>,
|
||||
// @ts-expect-error duplicate hook keys are rejected by the assertion
|
||||
Assert<DisjointBundles<FakeDuplicateHooks>>,
|
||||
];
|
||||
|
||||
type ExactInputAssertion = Assert<
|
||||
Equal<ExactJsonInput<Input, Input & { readonly extension: boolean }>["extension"], never>
|
||||
>;
|
||||
type CheckpointAssertion = Assert<
|
||||
{ readonly phase: "deferred"; readonly handle: Stored<DeferredHandle> } extends TaskCheckpoint ? true : false
|
||||
>;
|
||||
type HydrationAssertions = [
|
||||
Assert<Extract<InputOutcome, { status: "placed" }>["input"] extends Entry ? true : false>,
|
||||
Assert<
|
||||
Equal<
|
||||
Extract<InputOutcome, { status: "done" }>["answer"] extends infer A | undefined
|
||||
? NonNullable<A> extends { message: infer M }
|
||||
? M
|
||||
: never
|
||||
: never,
|
||||
AssistantMessage
|
||||
>
|
||||
>,
|
||||
ExtendsJson<StoredInputResult>,
|
||||
];
|
||||
type KindShape = TaskKind<Input, Checkpoint, Result, Failure, Aborted>;
|
||||
type RunningAssertion = Assert<"output" extends keyof RunningTask<Input, Checkpoint> ? false : true>;
|
||||
|
||||
declare const storedAssertions: StoredAssertions;
|
||||
declare const hookAssertions: HookAssertions;
|
||||
declare const kindAssertions: KindAssertions;
|
||||
declare const outcomeAssertions: OutcomeAssertions;
|
||||
declare const jobAssertions: JobAssertions;
|
||||
declare const entryAssertions: EntryAssertions;
|
||||
declare const bundleAssertions: BundleAssertions;
|
||||
declare const badBundleAssertions: BadBundleAssertions;
|
||||
declare const exactInputAssertion: ExactInputAssertion;
|
||||
declare const entry: Entry;
|
||||
void jsonAssertions;
|
||||
declare const checkpointAssertion: CheckpointAssertion;
|
||||
declare const hydrationAssertions: HydrationAssertions;
|
||||
declare const kindShape: KindShape;
|
||||
declare const runningAssertion: RunningAssertion;
|
||||
void storedAssertions;
|
||||
void hookAssertions;
|
||||
void kindAssertions;
|
||||
void outcomeAssertions;
|
||||
void jobAssertions;
|
||||
void entryAssertions;
|
||||
void bundleAssertions;
|
||||
void badBundleAssertions;
|
||||
void exactInputAssertion;
|
||||
void entry;
|
||||
void verifyTaskInputs;
|
||||
void verifyOrdinaryRegistration;
|
||||
void verifyCoreTaskInputs;
|
||||
void verifyOutputMutation;
|
||||
void verifyCheckpointCompatibility;
|
||||
void verifyOutcomes;
|
||||
void checkpointAssertion;
|
||||
void hydrationAssertions;
|
||||
void kindShape;
|
||||
void runningAssertion;
|
||||
void verifyNoOutputRuntime;
|
||||
void verifyOutput;
|
||||
void verifyJobMutation;
|
||||
void verifyEntryInputs;
|
||||
void verifyHostTaskCreation;
|
||||
void verifyTaskCreation;
|
||||
void verifyAddresses;
|
||||
void verifyResolvedTransactionReads;
|
||||
void (null as Message | null);
|
||||
void (null as CoreAbortTaskRuntime<Input, Checkpoint> | null);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue