specrails-core 6.4.0 → 6.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-runtime/capabilities.d.ts +2 -2
- package/dist/agent-runtime/capabilities.js +22 -5
- package/dist/agent-runtime/cli-executor.js +8 -3
- package/dist/agent-runtime/cli.d.ts +4 -4
- package/dist/agent-runtime/cli.js +50 -3
- package/dist/agent-runtime/compact/environment.d.ts +50 -4
- package/dist/agent-runtime/compact/environment.js +183 -6
- package/dist/agent-runtime/compact/step.d.ts +1 -1
- package/dist/agent-runtime/efficiency-summary.d.ts +1 -1
- package/dist/agent-runtime/efficiency-types.d.ts +1 -1
- package/dist/agent-runtime/engine/checkpoint/database.d.ts +15 -15
- package/dist/agent-runtime/engine/contracts.d.ts +2 -1
- package/dist/agent-runtime/engine/definition-types.d.ts +1 -1
- package/dist/agent-runtime/engine/efficiency-summary.d.ts +8 -8
- package/dist/agent-runtime/engine/fork.d.ts +1 -1
- package/dist/agent-runtime/engine/graph-description.d.ts +16 -16
- package/dist/agent-runtime/engine/implementation-compiler.d.ts +68 -68
- package/dist/agent-runtime/engine/piece-dependencies.js +4 -4
- package/dist/agent-runtime/engine/pieces/implementation-step.d.ts +1 -1
- package/dist/agent-runtime/engine/pieces/role-turn.d.ts +5 -0
- package/dist/agent-runtime/engine/pieces/role-turn.js +64 -22
- package/dist/agent-runtime/engine/pieces/shared.d.ts +2 -2
- package/dist/agent-runtime/engine/pieces/verify.js +46 -13
- package/dist/agent-runtime/engine/run-status.d.ts +165 -165
- package/dist/agent-runtime/engine/runs.d.ts +168 -168
- package/dist/agent-runtime/engine/state.d.ts +16 -16
- package/dist/agent-runtime/evaluation.d.ts +3 -3
- package/dist/agent-runtime/executor-types.d.ts +10 -10
- package/dist/agent-runtime/graph/convergence.d.ts +2 -2
- package/dist/agent-runtime/graph/convergence.js +11 -0
- package/dist/agent-runtime/graph/nodes.js +35 -4
- package/dist/agent-runtime/graph/review-policy.d.ts +1 -1
- package/dist/agent-runtime/graph/state.d.ts +6 -2
- package/dist/agent-runtime/guardrails.d.ts +1 -1
- package/dist/agent-runtime/guardrails.js +4 -1
- package/dist/agent-runtime/index.d.ts +3 -2
- package/dist/agent-runtime/index.js +1 -0
- package/dist/agent-runtime/openai-executor.d.ts +9 -9
- package/dist/agent-runtime/openspec.d.ts +4 -4
- package/dist/agent-runtime/prompts.d.ts +6 -0
- package/dist/agent-runtime/prompts.js +32 -8
- package/dist/agent-runtime/recovery.d.ts +12 -12
- package/dist/agent-runtime/session/application/active-session.d.ts +176 -0
- package/dist/agent-runtime/session/application/active-session.js +701 -0
- package/dist/agent-runtime/session/application/delegation.d.ts +73 -0
- package/dist/agent-runtime/session/application/delegation.js +171 -0
- package/dist/agent-runtime/session/application/output-buffer.d.ts +43 -0
- package/dist/agent-runtime/session/application/output-buffer.js +65 -0
- package/dist/agent-runtime/session/application/session-service.d.ts +121 -0
- package/dist/agent-runtime/session/application/session-service.js +256 -0
- package/dist/agent-runtime/session/application/timers.d.ts +15 -0
- package/dist/agent-runtime/session/application/timers.js +35 -0
- package/dist/agent-runtime/session/domain/descriptor.d.ts +6 -0
- package/dist/agent-runtime/session/domain/descriptor.js +32 -0
- package/dist/agent-runtime/session/domain/errors.d.ts +9 -0
- package/dist/agent-runtime/session/domain/errors.js +16 -0
- package/dist/agent-runtime/session/domain/events.d.ts +147 -0
- package/dist/agent-runtime/session/domain/events.js +24 -0
- package/dist/agent-runtime/session/domain/fingerprint.d.ts +2 -0
- package/dist/agent-runtime/session/domain/fingerprint.js +10 -0
- package/dist/agent-runtime/session/domain/interruption.d.ts +13 -0
- package/dist/agent-runtime/session/domain/interruption.js +39 -0
- package/dist/agent-runtime/session/domain/policy.d.ts +42 -0
- package/dist/agent-runtime/session/domain/policy.js +104 -0
- package/dist/agent-runtime/session/domain/snapshot.d.ts +100 -0
- package/dist/agent-runtime/session/domain/snapshot.js +234 -0
- package/dist/agent-runtime/session/domain/state-machines.d.ts +21 -0
- package/dist/agent-runtime/session/domain/state-machines.js +41 -0
- package/dist/agent-runtime/session/domain/types.d.ts +130 -0
- package/dist/agent-runtime/session/domain/types.js +15 -0
- package/dist/agent-runtime/session/domain/usage.d.ts +54 -0
- package/dist/agent-runtime/session/domain/usage.js +86 -0
- package/dist/agent-runtime/session/drivers/claude/argv.d.ts +19 -0
- package/dist/agent-runtime/session/drivers/claude/argv.js +67 -0
- package/dist/agent-runtime/session/drivers/claude/driver.d.ts +19 -0
- package/dist/agent-runtime/session/drivers/claude/driver.js +134 -0
- package/dist/agent-runtime/session/drivers/claude/translator.d.ts +40 -0
- package/dist/agent-runtime/session/drivers/claude/translator.js +247 -0
- package/dist/agent-runtime/session/drivers/codex/argv.d.ts +12 -0
- package/dist/agent-runtime/session/drivers/codex/argv.js +76 -0
- package/dist/agent-runtime/session/drivers/codex/driver.d.ts +19 -0
- package/dist/agent-runtime/session/drivers/codex/driver.js +193 -0
- package/dist/agent-runtime/session/drivers/codex/rpc.d.ts +28 -0
- package/dist/agent-runtime/session/drivers/codex/rpc.js +81 -0
- package/dist/agent-runtime/session/drivers/codex/translator.d.ts +53 -0
- package/dist/agent-runtime/session/drivers/codex/translator.js +267 -0
- package/dist/agent-runtime/session/drivers/common/process.d.ts +37 -0
- package/dist/agent-runtime/session/drivers/common/process.js +116 -0
- package/dist/agent-runtime/session/drivers/executor/driver.d.ts +22 -0
- package/dist/agent-runtime/session/drivers/executor/driver.js +163 -0
- package/dist/agent-runtime/session/drivers/registry.d.ts +32 -0
- package/dist/agent-runtime/session/drivers/registry.js +38 -0
- package/dist/agent-runtime/session/host/protocol.d.ts +18 -0
- package/dist/agent-runtime/session/host/protocol.js +88 -0
- package/dist/agent-runtime/session/host/server.d.ts +57 -0
- package/dist/agent-runtime/session/host/server.js +229 -0
- package/dist/agent-runtime/session/index.d.ts +45 -0
- package/dist/agent-runtime/session/index.js +74 -0
- package/dist/agent-runtime/session/journal/schema.d.ts +7 -0
- package/dist/agent-runtime/session/journal/schema.js +44 -0
- package/dist/agent-runtime/session/journal/sqlite-journal.d.ts +70 -0
- package/dist/agent-runtime/session/journal/sqlite-journal.js +279 -0
- package/dist/agent-runtime/session/ports.d.ts +192 -0
- package/dist/agent-runtime/session/ports.js +1 -0
- package/dist/agent-runtime/session/testing/driver-conformance.d.ts +21 -0
- package/dist/agent-runtime/session/testing/driver-conformance.js +97 -0
- package/dist/agent-runtime/session/testing/fake-clock.d.ts +20 -0
- package/dist/agent-runtime/session/testing/fake-clock.js +34 -0
- package/dist/agent-runtime/session/testing/fixture-replayer.d.ts +41 -0
- package/dist/agent-runtime/session/testing/fixture-replayer.js +172 -0
- package/dist/agent-runtime/session/testing/index.d.ts +9 -0
- package/dist/agent-runtime/session/testing/index.js +9 -0
- package/dist/agent-runtime/session/testing/memory-journal.d.ts +27 -0
- package/dist/agent-runtime/session/testing/memory-journal.js +70 -0
- package/dist/agent-runtime/session/testing/scripted-driver.d.ts +30 -0
- package/dist/agent-runtime/session/testing/scripted-driver.js +77 -0
- package/dist/agent-runtime/verification-proposals.d.ts +9 -0
- package/dist/agent-runtime/verification-proposals.js +69 -0
- package/dist/agent-runtime/verification-repair.d.ts +31 -2
- package/dist/agent-runtime/verification-repair.js +58 -3
- package/dist/installer/commands/init.d.ts +1 -1
- package/dist/installer/phases/install-config.js +10 -3
- package/dist/installer/util/paths.d.ts +25 -1
- package/dist/installer/util/registry.js +3 -3
- package/dist/pipeline/pipeline-state.d.ts +42 -11
- package/dist/pipeline/pipeline-state.js +47 -8
- package/dist/shared/specrails-home.d.ts +21 -0
- package/dist/shared/specrails-home.js +36 -0
- package/integration-contract.json +42 -1
- package/package.json +23 -11
- package/schemas/agent-runtime.schema.json +1 -1
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type ValidateFunction } from 'ajv/dist/2020.js';
|
|
2
|
+
/** Protocol versions this host speaks (docs/agent-sessions/protocol.md). */
|
|
3
|
+
export declare const SESSION_PROTOCOL_VERSIONS: readonly number[];
|
|
4
|
+
/** Longest accepted request line. */
|
|
5
|
+
export declare const MAX_FRAME_BYTES: number;
|
|
6
|
+
type Schema = Record<string, unknown>;
|
|
7
|
+
/** Closed parameter schemas, one per method. */
|
|
8
|
+
export declare const METHOD_SCHEMAS: Readonly<Record<string, Schema>>;
|
|
9
|
+
export type MethodName = keyof typeof METHOD_SCHEMAS;
|
|
10
|
+
/** Compiled validators (lazy, shared). */
|
|
11
|
+
export declare function validatorFor(method: string): ValidateFunction | undefined;
|
|
12
|
+
/** JSON-RPC error codes: standard ones plus one application range for SessionError codes. */
|
|
13
|
+
export declare const RPC_PARSE_ERROR = -32700;
|
|
14
|
+
export declare const RPC_INVALID_REQUEST = -32600;
|
|
15
|
+
export declare const RPC_METHOD_NOT_FOUND = -32601;
|
|
16
|
+
export declare const RPC_INVALID_PARAMS = -32602;
|
|
17
|
+
export declare const RPC_APPLICATION_ERROR = -32000;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
2
|
+
/** Protocol versions this host speaks (docs/agent-sessions/protocol.md). */
|
|
3
|
+
export const SESSION_PROTOCOL_VERSIONS = Object.freeze([1]);
|
|
4
|
+
/** Longest accepted request line. */
|
|
5
|
+
export const MAX_FRAME_BYTES = 2 * 1024 * 1024;
|
|
6
|
+
const id = { type: 'string', minLength: 1, maxLength: 200 };
|
|
7
|
+
const text = { type: 'string', maxLength: 1024 * 1024 };
|
|
8
|
+
const attachment = { type: 'object', additionalProperties: false, required: ['kind', 'path'], properties: { kind: { enum: ['image', 'file'] }, path: { type: 'string', minLength: 1, maxLength: 4096 }, mimeType: { type: 'string', maxLength: 200 } } };
|
|
9
|
+
const mcpServer = {
|
|
10
|
+
type: 'object', additionalProperties: false, required: ['name'],
|
|
11
|
+
properties: {
|
|
12
|
+
name: { type: 'string', pattern: '^[A-Za-z0-9_.-]{1,64}$' },
|
|
13
|
+
command: { type: 'string', maxLength: 4096 },
|
|
14
|
+
args: { type: 'array', maxItems: 64, items: { type: 'string', maxLength: 4096 } },
|
|
15
|
+
env: { type: 'object', additionalProperties: { type: 'string', maxLength: 16384 } },
|
|
16
|
+
url: { type: 'string', maxLength: 4096 },
|
|
17
|
+
headers: { type: 'object', additionalProperties: { type: 'string', maxLength: 16384 } },
|
|
18
|
+
autoApprove: { type: 'boolean' },
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
const limits = {
|
|
22
|
+
type: 'object', additionalProperties: false,
|
|
23
|
+
properties: Object.fromEntries(['idleMs', 'stallMs', 'backgroundMaxMs', 'turnInactivityMs', 'maxSettleHandoffs', 'settleDebounceMs'].map((key) => [key, { type: 'integer', minimum: 0 }])),
|
|
24
|
+
};
|
|
25
|
+
const policy = {
|
|
26
|
+
type: 'object', additionalProperties: false, required: ['subagents'],
|
|
27
|
+
properties: {
|
|
28
|
+
subagents: { enum: ['enabled', 'disabled'] },
|
|
29
|
+
onSubagentsSettled: { enum: ['provider-native', 'resume-agent', 'notify-only'] },
|
|
30
|
+
tools: { type: 'object', additionalProperties: false, required: ['mode'], properties: { mode: { enum: ['default', 'read-only', 'none'] }, allow: { type: 'array', maxItems: 200, items: { type: 'string', maxLength: 200 } }, deny: { type: 'array', maxItems: 200, items: { type: 'string', maxLength: 200 } } } },
|
|
31
|
+
permissions: { enum: ['bypass', 'workspace-write', 'read-only'] },
|
|
32
|
+
mcp: { type: 'object', additionalProperties: false, properties: { servers: { type: 'array', maxItems: 32, items: mcpServer }, inheritUserScope: { type: 'boolean' } } },
|
|
33
|
+
limits,
|
|
34
|
+
subagentRuntime: {
|
|
35
|
+
oneOf: [
|
|
36
|
+
{ type: 'object', additionalProperties: false, required: ['mode'], properties: { mode: { const: 'native' }, model: { type: 'string', minLength: 1, maxLength: 200 }, effort: { type: 'string', minLength: 1, maxLength: 50 } } },
|
|
37
|
+
{ type: 'object', additionalProperties: false, required: ['mode', 'driver', 'model'], properties: { mode: { const: 'delegated' }, driver: { type: 'string', pattern: '^[a-z0-9][a-z0-9-]{0,63}$' }, model: { type: 'string', minLength: 1, maxLength: 200 }, effort: { type: 'string', minLength: 1, maxLength: 50 }, maxConcurrent: { type: 'integer', minimum: 1, maximum: 16 } } },
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
/** Closed parameter schemas, one per method. */
|
|
43
|
+
export const METHOD_SCHEMAS = Object.freeze({
|
|
44
|
+
'initialize': { type: 'object', additionalProperties: false, required: ['protocolVersions', 'host'], properties: { protocolVersions: { type: 'array', minItems: 1, maxItems: 16, items: { type: 'integer', minimum: 1 } }, host: { type: 'object', additionalProperties: false, required: ['name', 'version'], properties: { name: { type: 'string', maxLength: 200 }, version: { type: 'string', maxLength: 200 } } }, scope: { type: 'string', maxLength: 128 } } },
|
|
45
|
+
'session.open': {
|
|
46
|
+
type: 'object', additionalProperties: false,
|
|
47
|
+
properties: {
|
|
48
|
+
sessionId: id,
|
|
49
|
+
resume: { type: 'object', additionalProperties: false, required: ['sessionId'], properties: { sessionId: id } },
|
|
50
|
+
driver: { type: 'string', pattern: '^[a-z0-9][a-z0-9._-]{0,63}$' },
|
|
51
|
+
model: { type: 'string', minLength: 1, maxLength: 200 },
|
|
52
|
+
effort: { type: 'string', maxLength: 50 },
|
|
53
|
+
cwd: { type: 'string', minLength: 1, maxLength: 4096 },
|
|
54
|
+
systemPrompt: text,
|
|
55
|
+
policy,
|
|
56
|
+
providerSessionRef: id,
|
|
57
|
+
metadata: { type: 'object' },
|
|
58
|
+
},
|
|
59
|
+
oneOf: [{ required: ['resume'] }, { required: ['driver', 'model', 'cwd', 'policy'] }],
|
|
60
|
+
},
|
|
61
|
+
'session.send': { type: 'object', additionalProperties: false, required: ['sessionId', 'input'], properties: { sessionId: id, input: { type: 'object', additionalProperties: false, required: ['inputId', 'text', 'delivery'], properties: { inputId: id, text, attachments: { type: 'array', maxItems: 32, items: attachment }, delivery: { enum: ['queue', 'steer'] } } } } },
|
|
62
|
+
'session.interrupt': { type: 'object', additionalProperties: false, required: ['sessionId'], properties: { sessionId: id } },
|
|
63
|
+
'session.stopSubagents': { type: 'object', additionalProperties: false, required: ['sessionId'], properties: { sessionId: id, subagentIds: { type: 'array', maxItems: 500, items: id } } },
|
|
64
|
+
'session.delegate': { type: 'object', additionalProperties: false, required: ['sessionId', 'description', 'prompt'], properties: { sessionId: id, description: { type: 'string', minLength: 1, maxLength: 200 }, prompt: { type: 'string', minLength: 1, maxLength: 64 * 1024 }, agentType: { type: 'string', minLength: 1, maxLength: 100 }, contextTurns: { type: 'integer', minimum: 0, maximum: 10 } } },
|
|
65
|
+
'session.waitSubagents': { type: 'object', additionalProperties: false, required: ['sessionId', 'timeoutMs'], properties: { sessionId: id, subagentIds: { type: 'array', maxItems: 500, items: id }, timeoutMs: { type: 'integer', minimum: 0, maximum: 30 * 60_000 } } },
|
|
66
|
+
'session.update': { type: 'object', additionalProperties: false, required: ['sessionId'], minProperties: 2, properties: { sessionId: id, model: { type: 'string', minLength: 1, maxLength: 200 }, effort: { type: 'string', maxLength: 50 }, systemPrompt: text, policy } },
|
|
67
|
+
'session.close': { type: 'object', additionalProperties: false, required: ['sessionId', 'reason'], properties: { sessionId: id, reason: { type: 'string', minLength: 1, maxLength: 200 } } },
|
|
68
|
+
'session.snapshot': { type: 'object', additionalProperties: false, required: ['sessionId'], properties: { sessionId: id } },
|
|
69
|
+
'session.events': { type: 'object', additionalProperties: false, required: ['sessionId', 'afterSeq'], properties: { sessionId: id, afterSeq: { type: 'integer', minimum: 0 }, limit: { type: 'integer', minimum: 1, maximum: 5000 } } },
|
|
70
|
+
'session.list': { type: 'object', additionalProperties: false, properties: { state: { enum: ['open', 'closed', 'all'] } } },
|
|
71
|
+
'host.ping': { type: 'object', additionalProperties: false },
|
|
72
|
+
'host.shutdown': { type: 'object', additionalProperties: false, properties: { graceMs: { type: 'integer', minimum: 0, maximum: 120000 } } },
|
|
73
|
+
});
|
|
74
|
+
let validators = null;
|
|
75
|
+
/** Compiled validators (lazy, shared). */
|
|
76
|
+
export function validatorFor(method) {
|
|
77
|
+
if (!validators) {
|
|
78
|
+
const ajv = new Ajv2020({ allErrors: false, strict: false, validateFormats: false, ownProperties: true });
|
|
79
|
+
validators = new Map(Object.entries(METHOD_SCHEMAS).map(([name, schema]) => [name, ajv.compile(schema)]));
|
|
80
|
+
}
|
|
81
|
+
return validators.get(method);
|
|
82
|
+
}
|
|
83
|
+
/** JSON-RPC error codes: standard ones plus one application range for SessionError codes. */
|
|
84
|
+
export const RPC_PARSE_ERROR = -32700;
|
|
85
|
+
export const RPC_INVALID_REQUEST = -32600;
|
|
86
|
+
export const RPC_METHOD_NOT_FOUND = -32601;
|
|
87
|
+
export const RPC_INVALID_PARAMS = -32602;
|
|
88
|
+
export const RPC_APPLICATION_ERROR = -32000;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { SessionService } from '../application/session-service.js';
|
|
2
|
+
type Json = Record<string, unknown>;
|
|
3
|
+
/** What the composition root hands the host. */
|
|
4
|
+
export interface HostRuntime {
|
|
5
|
+
service: SessionService;
|
|
6
|
+
scope: string;
|
|
7
|
+
identity: Json;
|
|
8
|
+
/** Retire providers, record interruptions and release the journal. */
|
|
9
|
+
close(): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
export interface HostIo {
|
|
12
|
+
input: NodeJS.ReadableStream;
|
|
13
|
+
output: NodeJS.WritableStream;
|
|
14
|
+
}
|
|
15
|
+
export interface SessionHostOptions {
|
|
16
|
+
/** Queued notifications before the busiest session is marked lagged. */
|
|
17
|
+
notificationQueueLimit?: number;
|
|
18
|
+
/** Default grace for provider shutdown. */
|
|
19
|
+
shutdownGraceMs?: number;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* JSON-RPC 2.0 server over NDJSON stdio (session protocol v1).
|
|
23
|
+
* One handler per method (closed table); each validates its params with a
|
|
24
|
+
* closed schema and calls exactly one application use case.
|
|
25
|
+
*/
|
|
26
|
+
export declare class SessionHost {
|
|
27
|
+
private readonly runtime;
|
|
28
|
+
private readonly io;
|
|
29
|
+
private readonly options;
|
|
30
|
+
private initialized;
|
|
31
|
+
private stopping;
|
|
32
|
+
private readonly lastSent;
|
|
33
|
+
private readonly notifications;
|
|
34
|
+
private readonly responses;
|
|
35
|
+
private writing;
|
|
36
|
+
private readonly handlers;
|
|
37
|
+
private unsubscribe;
|
|
38
|
+
private finished;
|
|
39
|
+
readonly done: Promise<void>;
|
|
40
|
+
constructor(runtime: HostRuntime, io: HostIo, options?: SessionHostOptions);
|
|
41
|
+
/** Serve until input ends or `host.shutdown`; resolves after a clean stop. */
|
|
42
|
+
start(): Promise<void>;
|
|
43
|
+
/** Another host took the journal: tell the client and stop without further writes. */
|
|
44
|
+
leaseLost(): void;
|
|
45
|
+
stop(): Promise<void>;
|
|
46
|
+
private handleLine;
|
|
47
|
+
private initialize;
|
|
48
|
+
private publish;
|
|
49
|
+
/** Keep the live feed bounded: drop the busiest session's queued events after a lag notice. */
|
|
50
|
+
private shed;
|
|
51
|
+
private respondError;
|
|
52
|
+
private queueResponse;
|
|
53
|
+
/** Responses first (never dropped), then notifications; honours stream backpressure. */
|
|
54
|
+
private drain;
|
|
55
|
+
private flush;
|
|
56
|
+
}
|
|
57
|
+
export {};
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
2
|
+
import { SessionError, isSessionError } from '../domain/errors.js';
|
|
3
|
+
import { MAX_FRAME_BYTES, RPC_APPLICATION_ERROR, RPC_INVALID_PARAMS, RPC_INVALID_REQUEST, RPC_METHOD_NOT_FOUND, RPC_PARSE_ERROR, SESSION_PROTOCOL_VERSIONS, validatorFor, } from './protocol.js';
|
|
4
|
+
/**
|
|
5
|
+
* JSON-RPC 2.0 server over NDJSON stdio (session protocol v1).
|
|
6
|
+
* One handler per method (closed table); each validates its params with a
|
|
7
|
+
* closed schema and calls exactly one application use case.
|
|
8
|
+
*/
|
|
9
|
+
export class SessionHost {
|
|
10
|
+
runtime;
|
|
11
|
+
io;
|
|
12
|
+
options;
|
|
13
|
+
initialized = false;
|
|
14
|
+
stopping = null;
|
|
15
|
+
lastSent = new Map();
|
|
16
|
+
notifications = [];
|
|
17
|
+
responses = [];
|
|
18
|
+
writing = false;
|
|
19
|
+
handlers;
|
|
20
|
+
unsubscribe = null;
|
|
21
|
+
finished;
|
|
22
|
+
done = new Promise((resolve) => { this.finished = resolve; });
|
|
23
|
+
constructor(runtime, io, options = {}) {
|
|
24
|
+
this.runtime = runtime;
|
|
25
|
+
this.io = io;
|
|
26
|
+
this.options = options;
|
|
27
|
+
const service = runtime.service;
|
|
28
|
+
this.handlers = Object.freeze({
|
|
29
|
+
'initialize': async (params) => this.initialize(params),
|
|
30
|
+
'session.open': async (params) => service.open(params),
|
|
31
|
+
'session.send': async (params) => service.send(String(params.sessionId), params.input),
|
|
32
|
+
'session.interrupt': async (params) => service.interrupt(String(params.sessionId)),
|
|
33
|
+
'session.delegate': async (params) => service.delegate(String(params.sessionId), {
|
|
34
|
+
description: String(params.description),
|
|
35
|
+
prompt: String(params.prompt),
|
|
36
|
+
...(typeof params.agentType === 'string' ? { agentType: params.agentType } : {}),
|
|
37
|
+
...(typeof params.contextTurns === 'number' ? { contextTurns: params.contextTurns } : {}),
|
|
38
|
+
}),
|
|
39
|
+
'session.waitSubagents': async (params) => service.waitSubagents(String(params.sessionId), Array.isArray(params.subagentIds) ? params.subagentIds.map(String) : undefined, Number(params.timeoutMs)),
|
|
40
|
+
'session.stopSubagents': async (params) => service.stopSubagents(String(params.sessionId), params.subagentIds),
|
|
41
|
+
'session.update': async (params) => { const { sessionId, ...changes } = params; return service.update(String(sessionId), changes); },
|
|
42
|
+
'session.close': async (params) => { await service.close(String(params.sessionId), String(params.reason)); return {}; },
|
|
43
|
+
'session.snapshot': async (params) => service.snapshot(String(params.sessionId)),
|
|
44
|
+
'session.events': async (params) => service.events(String(params.sessionId), Number(params.afterSeq), params.limit === undefined ? undefined : Number(params.limit)),
|
|
45
|
+
'session.list': async (params) => ({ sessions: service.list(params.state ?? 'open') }),
|
|
46
|
+
'host.ping': async () => ({ ...service.stats(), uptimeMs: Math.round(process.uptime() * 1000) }),
|
|
47
|
+
'host.shutdown': async () => { setImmediate(() => void this.stop()); return {}; },
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
/** Serve until input ends or `host.shutdown`; resolves after a clean stop. */
|
|
51
|
+
start() {
|
|
52
|
+
this.unsubscribe = this.runtime.service.subscribe((envelopes) => this.publish(envelopes));
|
|
53
|
+
const decoder = new StringDecoder('utf8');
|
|
54
|
+
let pending = '';
|
|
55
|
+
this.io.input.on('data', (chunk) => {
|
|
56
|
+
pending += typeof chunk === 'string' ? chunk : decoder.write(chunk);
|
|
57
|
+
for (;;) {
|
|
58
|
+
const newline = pending.indexOf('\n');
|
|
59
|
+
if (newline < 0)
|
|
60
|
+
break;
|
|
61
|
+
const line = pending.slice(0, newline).replace(/\r$/, '');
|
|
62
|
+
pending = pending.slice(newline + 1);
|
|
63
|
+
if (line.trim())
|
|
64
|
+
void this.handleLine(line);
|
|
65
|
+
}
|
|
66
|
+
if (Buffer.byteLength(pending) > MAX_FRAME_BYTES) {
|
|
67
|
+
pending = '';
|
|
68
|
+
this.respondError(null, RPC_INVALID_REQUEST, `Request exceeds ${MAX_FRAME_BYTES} bytes`, { code: 'payload_too_large', retryable: false });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
this.io.input.on('end', () => void this.stop());
|
|
72
|
+
return this.done;
|
|
73
|
+
}
|
|
74
|
+
/** Another host took the journal: tell the client and stop without further writes. */
|
|
75
|
+
leaseLost() {
|
|
76
|
+
this.queueResponse(JSON.stringify({ jsonrpc: '2.0', method: 'host.leaseLost', params: {} }));
|
|
77
|
+
void this.stop();
|
|
78
|
+
}
|
|
79
|
+
stop() {
|
|
80
|
+
this.stopping ??= (async () => {
|
|
81
|
+
this.unsubscribe?.();
|
|
82
|
+
try {
|
|
83
|
+
await this.runtime.close();
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
process.stderr.write(`session host: shutdown failed: ${error.message}\n`);
|
|
87
|
+
}
|
|
88
|
+
await this.flush();
|
|
89
|
+
this.finished();
|
|
90
|
+
})();
|
|
91
|
+
return this.stopping;
|
|
92
|
+
}
|
|
93
|
+
// ── requests ──────────────────────────────────────────────────────────────
|
|
94
|
+
async handleLine(line) {
|
|
95
|
+
if (Buffer.byteLength(line) > MAX_FRAME_BYTES) {
|
|
96
|
+
this.respondError(null, RPC_INVALID_REQUEST, `Request exceeds ${MAX_FRAME_BYTES} bytes`, { code: 'payload_too_large', retryable: false });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
let request;
|
|
100
|
+
try {
|
|
101
|
+
request = JSON.parse(line);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
this.respondError(null, RPC_PARSE_ERROR, 'Invalid JSON', { code: 'invalid_params', retryable: false });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const id = typeof request.id === 'string' || typeof request.id === 'number' ? request.id : null;
|
|
108
|
+
const method = request.method;
|
|
109
|
+
if (typeof method !== 'string') {
|
|
110
|
+
this.respondError(id, RPC_INVALID_REQUEST, 'Missing method', { code: 'invalid_params', retryable: false });
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (id === null)
|
|
114
|
+
return; // notifications from the client carry no work in protocol v1
|
|
115
|
+
const handler = Object.hasOwn(this.handlers, method) ? this.handlers[method] : undefined;
|
|
116
|
+
if (!handler) {
|
|
117
|
+
this.respondError(id, RPC_METHOD_NOT_FOUND, `Unknown method ${method}`, { code: 'invalid_params', retryable: false });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (this.stopping && method !== 'host.ping') {
|
|
121
|
+
this.respondError(id, RPC_APPLICATION_ERROR, 'The session host is shutting down', { code: 'busy', retryable: true });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (!this.initialized && method !== 'initialize' && method !== 'host.ping') {
|
|
125
|
+
this.respondError(id, RPC_APPLICATION_ERROR, 'Call initialize first', { code: 'not_initialized', retryable: false });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const params = (request.params ?? {});
|
|
129
|
+
const validate = validatorFor(method);
|
|
130
|
+
if (validate && !validate(params)) {
|
|
131
|
+
const first = validate.errors?.[0];
|
|
132
|
+
this.respondError(id, RPC_INVALID_PARAMS, `Invalid params${first ? ` at ${first.instancePath || '/'}: ${first.message ?? ''}` : ''}`, { code: 'invalid_params', retryable: false, detail: { path: first?.instancePath ?? '' } });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
const result = await handler(params);
|
|
137
|
+
this.queueResponse(JSON.stringify({ jsonrpc: '2.0', id, result: result ?? {} }));
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
if (isSessionError(error)) {
|
|
141
|
+
this.respondError(id, error.code === 'invalid_params' ? RPC_INVALID_PARAMS : RPC_APPLICATION_ERROR, error.message, { code: error.code, retryable: error.retryable, ...(error.detail ? { detail: error.detail } : {}) });
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
process.stderr.write(`session host: ${method} failed: ${error.stack ?? String(error)}\n`);
|
|
145
|
+
this.respondError(id, RPC_APPLICATION_ERROR, error.message || 'Internal error', { code: 'internal', retryable: true });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
initialize(params) {
|
|
150
|
+
const requested = params.protocolVersions.filter((version) => SESSION_PROTOCOL_VERSIONS.includes(version));
|
|
151
|
+
if (requested.length === 0) {
|
|
152
|
+
throw new SessionError('protocol_mismatch', `No common session protocol version; this host supports ${SESSION_PROTOCOL_VERSIONS.join(', ')}`, { supported: [...SESSION_PROTOCOL_VERSIONS] });
|
|
153
|
+
}
|
|
154
|
+
if (params.scope !== undefined && params.scope !== this.runtime.scope) {
|
|
155
|
+
throw new SessionError('invalid_params', `This host serves scope "${this.runtime.scope}", not "${String(params.scope)}"`, { path: '/scope' });
|
|
156
|
+
}
|
|
157
|
+
this.initialized = true;
|
|
158
|
+
return {
|
|
159
|
+
protocolVersion: Math.max(...requested),
|
|
160
|
+
scope: this.runtime.scope,
|
|
161
|
+
runtime: this.runtime.identity,
|
|
162
|
+
// `delegation`: session.delegate / session.waitSubagents and subagentRuntime.mode 'delegated'.
|
|
163
|
+
capabilities: { sessions: 1, delegation: 1 },
|
|
164
|
+
drivers: this.runtime.service.drivers(),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
// ── output ────────────────────────────────────────────────────────────────
|
|
168
|
+
publish(envelopes) {
|
|
169
|
+
if (!this.initialized)
|
|
170
|
+
return;
|
|
171
|
+
for (const envelope of envelopes) {
|
|
172
|
+
this.notifications.push({
|
|
173
|
+
sessionId: envelope.sessionId,
|
|
174
|
+
seq: envelope.seq,
|
|
175
|
+
line: JSON.stringify({ jsonrpc: '2.0', method: 'session.event', params: { sessionId: envelope.sessionId, seq: envelope.seq, event: envelope.event } }),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
this.shed();
|
|
179
|
+
void this.drain();
|
|
180
|
+
}
|
|
181
|
+
/** Keep the live feed bounded: drop the busiest session's queued events after a lag notice. */
|
|
182
|
+
shed() {
|
|
183
|
+
const limit = this.options.notificationQueueLimit ?? 5_000;
|
|
184
|
+
while (this.notifications.length > limit) {
|
|
185
|
+
const counts = new Map();
|
|
186
|
+
for (const item of this.notifications)
|
|
187
|
+
counts.set(item.sessionId, (counts.get(item.sessionId) ?? 0) + 1);
|
|
188
|
+
const busiest = [...counts.entries()].sort((a, b) => b[1] - a[1])[0][0];
|
|
189
|
+
const kept = this.notifications.filter((item) => item.sessionId !== busiest);
|
|
190
|
+
this.notifications.length = 0;
|
|
191
|
+
this.notifications.push(...kept);
|
|
192
|
+
this.queueResponse(JSON.stringify({ jsonrpc: '2.0', method: 'session.lagged', params: { sessionId: busiest, deliveredSeq: this.lastSent.get(busiest) ?? 0 } }));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
respondError(id, code, message, data) {
|
|
196
|
+
this.queueResponse(JSON.stringify({ jsonrpc: '2.0', id, error: { code, message, data } }));
|
|
197
|
+
}
|
|
198
|
+
queueResponse(line) {
|
|
199
|
+
this.responses.push(line);
|
|
200
|
+
void this.drain();
|
|
201
|
+
}
|
|
202
|
+
/** Responses first (never dropped), then notifications; honours stream backpressure. */
|
|
203
|
+
async drain() {
|
|
204
|
+
if (this.writing)
|
|
205
|
+
return;
|
|
206
|
+
this.writing = true;
|
|
207
|
+
try {
|
|
208
|
+
for (;;) {
|
|
209
|
+
const response = this.responses.shift();
|
|
210
|
+
const notification = response === undefined ? this.notifications.shift() : undefined;
|
|
211
|
+
const line = response ?? notification?.line;
|
|
212
|
+
if (line === undefined)
|
|
213
|
+
break;
|
|
214
|
+
if (notification)
|
|
215
|
+
this.lastSent.set(notification.sessionId, notification.seq);
|
|
216
|
+
if (!this.io.output.write(`${line}\n`))
|
|
217
|
+
await new Promise((resolve) => this.io.output.once('drain', () => resolve()));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
this.writing = false;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
async flush() {
|
|
225
|
+
await this.drain();
|
|
226
|
+
while (this.responses.length > 0 || this.notifications.length > 0)
|
|
227
|
+
await this.drain();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { GLOBAL_SESSION_SCOPE } from '../../shared/specrails-home.js';
|
|
2
|
+
import { SessionService } from './application/session-service.js';
|
|
3
|
+
import type { RateCard } from './domain/usage.js';
|
|
4
|
+
import { type DriverRegistryOptions } from './drivers/registry.js';
|
|
5
|
+
import { SqliteSessionJournal } from './journal/sqlite-journal.js';
|
|
6
|
+
import type { Clock, DriverCatalog, Ids } from './ports.js';
|
|
7
|
+
export { SessionError, isSessionError, type SessionErrorCode } from './domain/errors.js';
|
|
8
|
+
export { SESSION_EVENT_TYPES, type SessionEvent, type SessionEventBody, type SessionEventEnvelope, type SessionEventType, type ToolActivity } from './domain/events.js';
|
|
9
|
+
export type { SessionSnapshot, SubagentNode, TurnRecord, InputRecord, OpenTurn } from './domain/snapshot.js';
|
|
10
|
+
export type { DriverCapabilities, DriverDescriptor, SessionLimits, SessionPolicy, Usage, McpServerSpec, Attachment } from './domain/types.js';
|
|
11
|
+
export type { SessionPolicyInput } from './domain/policy.js';
|
|
12
|
+
export { DEFAULT_LIMITS } from './domain/policy.js';
|
|
13
|
+
export { SessionService, type OpenParams, type SendParams } from './application/session-service.js';
|
|
14
|
+
export { createDriverRegistry, DriverRegistry, type DriverRegistryOptions } from './drivers/registry.js';
|
|
15
|
+
export type { DriverCatalog, DriverFactory, DriverSession, DriverEvent, SessionJournal } from './ports.js';
|
|
16
|
+
export { GLOBAL_SESSION_SCOPE };
|
|
17
|
+
export declare const systemClock: Clock;
|
|
18
|
+
export declare const randomIds: Ids;
|
|
19
|
+
export interface SessionRuntimeOptions {
|
|
20
|
+
/** Project key (slug) or `global`. */
|
|
21
|
+
scope?: string;
|
|
22
|
+
/** Base home for `~/.specrails` (defaults honour SPECRAILS_REGISTRY_HOME). */
|
|
23
|
+
home?: string;
|
|
24
|
+
/** Lease owner label; defaults to `host-<pid>`. */
|
|
25
|
+
owner?: string;
|
|
26
|
+
drivers?: DriverCatalog | DriverRegistryOptions;
|
|
27
|
+
clock?: Clock;
|
|
28
|
+
ids?: Ids;
|
|
29
|
+
rateCard?: (driver: string, model: string) => RateCard | null;
|
|
30
|
+
maxResident?: number;
|
|
31
|
+
/** Called when another host takes over the journal; the runtime has stopped writing. */
|
|
32
|
+
onLeaseLost?: () => void;
|
|
33
|
+
/** Explicit journal file (tests). */
|
|
34
|
+
journalFile?: string;
|
|
35
|
+
}
|
|
36
|
+
export interface SessionRuntime {
|
|
37
|
+
scope: string;
|
|
38
|
+
service: SessionService;
|
|
39
|
+
journal: SqliteSessionJournal;
|
|
40
|
+
/** Sessions whose running work was recorded as interrupted at startup. */
|
|
41
|
+
recovered: string[];
|
|
42
|
+
/** Retire providers, record interruptions, release the lease. Idempotent. */
|
|
43
|
+
close(): Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
export declare function createSessionRuntime(options?: SessionRuntimeOptions): Promise<SessionRuntime>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent session runtime — public API and composition root.
|
|
3
|
+
*
|
|
4
|
+
* `createSessionRuntime` is the only place where the journal, drivers and the
|
|
5
|
+
* application are wired together. Hosts talk to a running runtime over the
|
|
6
|
+
* session protocol (`specrails-core runtime host --stdio`); embedding it
|
|
7
|
+
* in-process is supported for tests and tools.
|
|
8
|
+
*/
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
10
|
+
import { GLOBAL_SESSION_SCOPE } from '../../shared/specrails-home.js';
|
|
11
|
+
import { SessionService } from './application/session-service.js';
|
|
12
|
+
import { createDriverRegistry } from './drivers/registry.js';
|
|
13
|
+
import { SqliteSessionJournal } from './journal/sqlite-journal.js';
|
|
14
|
+
export { SessionError, isSessionError } from './domain/errors.js';
|
|
15
|
+
export { SESSION_EVENT_TYPES } from './domain/events.js';
|
|
16
|
+
export { DEFAULT_LIMITS } from './domain/policy.js';
|
|
17
|
+
export { SessionService } from './application/session-service.js';
|
|
18
|
+
export { createDriverRegistry, DriverRegistry } from './drivers/registry.js';
|
|
19
|
+
export { GLOBAL_SESSION_SCOPE };
|
|
20
|
+
export const systemClock = {
|
|
21
|
+
now: () => Date.now(),
|
|
22
|
+
iso: () => new Date().toISOString(),
|
|
23
|
+
after(ms, callback) {
|
|
24
|
+
const timer = setTimeout(callback, Math.max(0, ms));
|
|
25
|
+
timer.unref?.();
|
|
26
|
+
return { cancel: () => clearTimeout(timer) };
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
export const randomIds = {
|
|
30
|
+
session: () => `ses_${randomUUID()}`,
|
|
31
|
+
turn: () => `turn_${randomUUID()}`,
|
|
32
|
+
input: () => `in_${randomUUID()}`,
|
|
33
|
+
};
|
|
34
|
+
function isCatalog(value) {
|
|
35
|
+
return !!value && typeof value.get === 'function' && typeof value.descriptors === 'function';
|
|
36
|
+
}
|
|
37
|
+
export async function createSessionRuntime(options = {}) {
|
|
38
|
+
const scope = options.scope ?? GLOBAL_SESSION_SCOPE;
|
|
39
|
+
let leaseLost = false;
|
|
40
|
+
const journal = await SqliteSessionJournal.open({
|
|
41
|
+
scope,
|
|
42
|
+
owner: options.owner ?? `host-${process.pid}`,
|
|
43
|
+
...(options.home ? { home: options.home } : {}),
|
|
44
|
+
...(options.journalFile ? { filename: options.journalFile } : {}),
|
|
45
|
+
onLeaseLost: () => { leaseLost = true; options.onLeaseLost?.(); },
|
|
46
|
+
});
|
|
47
|
+
const drivers = isCatalog(options.drivers) ? options.drivers : createDriverRegistry(options.drivers ?? {});
|
|
48
|
+
const serviceOptions = {
|
|
49
|
+
journal,
|
|
50
|
+
drivers,
|
|
51
|
+
clock: options.clock ?? systemClock,
|
|
52
|
+
ids: options.ids ?? randomIds,
|
|
53
|
+
...(options.rateCard ? { rateCard: options.rateCard } : {}),
|
|
54
|
+
...(options.maxResident !== undefined ? { maxResident: options.maxResident } : {}),
|
|
55
|
+
};
|
|
56
|
+
const service = new SessionService(serviceOptions);
|
|
57
|
+
// A predecessor that died without releasing the lease lost its host; otherwise it restarted.
|
|
58
|
+
const recovered = service.recover(journal.lease.previous === 'lost' ? 'host_lost' : 'restart');
|
|
59
|
+
let closing = null;
|
|
60
|
+
return {
|
|
61
|
+
scope,
|
|
62
|
+
service,
|
|
63
|
+
journal,
|
|
64
|
+
recovered,
|
|
65
|
+
close() {
|
|
66
|
+
closing ??= (async () => {
|
|
67
|
+
if (!leaseLost)
|
|
68
|
+
await service.shutdown();
|
|
69
|
+
journal.close();
|
|
70
|
+
})();
|
|
71
|
+
return closing;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ordered, forward-only migrations for the long-lived per-scope journal.
|
|
3
|
+
* `PRAGMA user_version` equals the number of applied migrations. Never edit or
|
|
4
|
+
* reorder a shipped entry; append a new one. Unknown future versions are refused.
|
|
5
|
+
*/
|
|
6
|
+
export declare const JOURNAL_MIGRATIONS: readonly string[];
|
|
7
|
+
export declare const JOURNAL_SCHEMA_VERSION: number;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ordered, forward-only migrations for the long-lived per-scope journal.
|
|
3
|
+
* `PRAGMA user_version` equals the number of applied migrations. Never edit or
|
|
4
|
+
* reorder a shipped entry; append a new one. Unknown future versions are refused.
|
|
5
|
+
*/
|
|
6
|
+
export const JOURNAL_MIGRATIONS = Object.freeze([
|
|
7
|
+
// 1 — sessions, events, usage baselines, host lease
|
|
8
|
+
`
|
|
9
|
+
CREATE TABLE sessions (
|
|
10
|
+
session_id TEXT PRIMARY KEY,
|
|
11
|
+
driver TEXT NOT NULL,
|
|
12
|
+
cwd TEXT NOT NULL,
|
|
13
|
+
metadata_json TEXT NOT NULL,
|
|
14
|
+
status TEXT NOT NULL CHECK(status IN ('open','closed')),
|
|
15
|
+
last_seq INTEGER NOT NULL DEFAULT 0,
|
|
16
|
+
snapshot_json TEXT NOT NULL,
|
|
17
|
+
created_at TEXT NOT NULL,
|
|
18
|
+
updated_at TEXT NOT NULL);
|
|
19
|
+
CREATE TABLE events (
|
|
20
|
+
session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE,
|
|
21
|
+
seq INTEGER NOT NULL,
|
|
22
|
+
type TEXT NOT NULL,
|
|
23
|
+
at TEXT NOT NULL,
|
|
24
|
+
payload_json TEXT NOT NULL,
|
|
25
|
+
PRIMARY KEY(session_id, seq)) WITHOUT ROWID;
|
|
26
|
+
CREATE TABLE usage_baselines (
|
|
27
|
+
provider_ref TEXT PRIMARY KEY,
|
|
28
|
+
baseline_json TEXT NOT NULL,
|
|
29
|
+
updated_at TEXT NOT NULL);
|
|
30
|
+
CREATE TABLE host_lease (
|
|
31
|
+
singleton INTEGER PRIMARY KEY CHECK(singleton = 1),
|
|
32
|
+
owner TEXT NOT NULL,
|
|
33
|
+
epoch INTEGER NOT NULL,
|
|
34
|
+
acquired_at TEXT NOT NULL,
|
|
35
|
+
heartbeat_at TEXT NOT NULL,
|
|
36
|
+
expires_at INTEGER NOT NULL);
|
|
37
|
+
CREATE TABLE lease_epoch (
|
|
38
|
+
singleton INTEGER PRIMARY KEY CHECK(singleton = 1),
|
|
39
|
+
epoch INTEGER NOT NULL);
|
|
40
|
+
INSERT INTO lease_epoch(singleton, epoch) VALUES (1, 0);
|
|
41
|
+
CREATE INDEX sessions_status ON sessions(status, updated_at);
|
|
42
|
+
`,
|
|
43
|
+
]);
|
|
44
|
+
export const JOURNAL_SCHEMA_VERSION = JOURNAL_MIGRATIONS.length;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { SessionError } from '../domain/errors.js';
|
|
2
|
+
import type { SessionEvent, SessionEventEnvelope } from '../domain/events.js';
|
|
3
|
+
import { type SessionSnapshot } from '../domain/snapshot.js';
|
|
4
|
+
import type { UsageBaseline } from '../domain/usage.js';
|
|
5
|
+
import type { AppendOptions, SessionJournal, SessionRecord, SessionSummary } from '../ports.js';
|
|
6
|
+
export declare const JOURNAL_LEASE_MS = 60000;
|
|
7
|
+
export declare const JOURNAL_HEARTBEAT_MS = 15000;
|
|
8
|
+
export declare const JOURNAL_FILENAME = "sessions.sqlite";
|
|
9
|
+
export interface JournalLease {
|
|
10
|
+
owner: string;
|
|
11
|
+
epoch: number;
|
|
12
|
+
expiresAt: number;
|
|
13
|
+
/** How the previous owner left: released cleanly, lost (expired), or first ever owner. */
|
|
14
|
+
previous: 'none' | 'released' | 'lost';
|
|
15
|
+
}
|
|
16
|
+
export interface OpenJournalOptions {
|
|
17
|
+
scope: string;
|
|
18
|
+
owner: string;
|
|
19
|
+
home?: string;
|
|
20
|
+
/** Explicit database file (tests); defaults to `<sessionsRoot(scope)>/sessions.sqlite`. */
|
|
21
|
+
filename?: string;
|
|
22
|
+
now?: () => number;
|
|
23
|
+
leaseMs?: number;
|
|
24
|
+
/** Called when the heartbeat cannot renew the lease; the host must stop writing. */
|
|
25
|
+
onLeaseLost?: (error: SessionError) => void;
|
|
26
|
+
/** Disable the heartbeat timer (tests drive `renew` explicitly). */
|
|
27
|
+
heartbeat?: boolean;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Durable per-scope session journal (`~/.specrails/sessions/<scope>/sessions.sqlite`).
|
|
31
|
+
* One host owns it through an epoch-fenced lease that is asserted inside every
|
|
32
|
+
* write transaction. Events and the folded snapshot commit together; provider
|
|
33
|
+
* I/O never runs inside a transaction.
|
|
34
|
+
*/
|
|
35
|
+
export declare class SqliteSessionJournal implements SessionJournal {
|
|
36
|
+
readonly filename: string;
|
|
37
|
+
private readonly db;
|
|
38
|
+
readonly lease: JournalLease;
|
|
39
|
+
private readonly now;
|
|
40
|
+
private readonly leaseMs;
|
|
41
|
+
private readonly cache;
|
|
42
|
+
private heartbeatTimer;
|
|
43
|
+
private closed;
|
|
44
|
+
private constructor();
|
|
45
|
+
static open(options: OpenJournalOptions): Promise<SqliteSessionJournal>;
|
|
46
|
+
createSession(record: SessionRecord): void;
|
|
47
|
+
getSession(sessionId: string): SessionRecord | null;
|
|
48
|
+
append(sessionId: string, events: SessionEvent[], options?: AppendOptions): SessionEventEnvelope[];
|
|
49
|
+
read(sessionId: string, afterSeq: number, limit: number): {
|
|
50
|
+
events: SessionEventEnvelope[];
|
|
51
|
+
hasMore: boolean;
|
|
52
|
+
};
|
|
53
|
+
snapshot(sessionId: string): SessionSnapshot | null;
|
|
54
|
+
list(filter: 'open' | 'closed' | 'all'): SessionSummary[];
|
|
55
|
+
baseline(providerRef: string): UsageBaseline | null;
|
|
56
|
+
/** Remove closed sessions last updated before `olderThan` (epoch ms). Open sessions are never removed. */
|
|
57
|
+
sweepClosed(olderThan: number): number;
|
|
58
|
+
/** Extend the lease; throws `journal_locked` when it was lost. */
|
|
59
|
+
renew(): void;
|
|
60
|
+
/** Release the lease (clean shutdown) and close the database. Idempotent. */
|
|
61
|
+
close(): void;
|
|
62
|
+
checkIntegrity(): void;
|
|
63
|
+
private startHeartbeat;
|
|
64
|
+
private stopHeartbeat;
|
|
65
|
+
private loadSnapshot;
|
|
66
|
+
/** A fenced write: the lease is asserted inside the same transaction. */
|
|
67
|
+
private write;
|
|
68
|
+
private transaction;
|
|
69
|
+
private assertLease;
|
|
70
|
+
}
|