@m6d/cortex-server 1.7.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -3
- package/contracts/README.md +23 -0
- package/contracts/graph/embed.ts +50 -0
- package/{src → contracts}/graph/helpers.ts +4 -10
- package/{src → contracts}/graph/neo4j.ts +14 -27
- package/contracts/graph/schema.ts +65 -0
- package/{src → contracts}/graph/types.ts +2 -29
- package/contracts/graph.ts +36 -0
- package/contracts/runtime.ts +208 -0
- package/contracts/wire.ts +143 -0
- package/dist/contracts/graph/embed.d.ts +22 -0
- package/dist/{src → contracts}/graph/helpers.d.ts +14 -12
- package/dist/{src → contracts}/graph/neo4j.d.ts +5 -5
- package/dist/contracts/graph/schema.d.ts +62 -0
- package/dist/{src → contracts}/graph/types.d.ts +1 -1
- package/dist/contracts/graph.d.ts +14 -0
- package/dist/contracts/runtime.d.ts +278 -0
- package/dist/contracts/wire.d.ts +120 -0
- package/dist/index.d.ts +1 -1
- package/dist/src/lib/adapters/database/index.d.ts +61 -0
- package/dist/src/lib/adapters/database/message-content.d.ts +30 -0
- package/dist/src/lib/adapters/database/mssql/attachments.d.ts +100 -0
- package/dist/src/lib/adapters/database/mssql/client.d.ts +8 -0
- package/dist/src/lib/adapters/database/mssql/index.d.ts +194 -0
- package/dist/src/lib/adapters/database/mssql/llm-requests.d.ts +17 -0
- package/dist/src/lib/adapters/database/mssql/messages.d.ts +37 -0
- package/dist/src/lib/adapters/database/mssql/threads.d.ts +50 -0
- package/dist/src/lib/adapters/database/postgres/attachments.d.ts +113 -0
- package/dist/src/lib/adapters/database/postgres/client.d.ts +8 -0
- package/dist/src/lib/adapters/database/postgres/index.d.ts +207 -0
- package/dist/src/lib/adapters/database/postgres/llm-requests.d.ts +17 -0
- package/dist/src/lib/adapters/database/postgres/messages.d.ts +37 -0
- package/dist/src/lib/adapters/database/postgres/threads.d.ts +50 -0
- package/{src/adapters/storage.ts → dist/src/lib/adapters/storage/index.d.ts} +1 -1
- package/dist/src/{adapters → lib/adapters/storage}/minio.d.ts +1 -1
- package/dist/src/lib/ai/active-runs.d.ts +63 -0
- package/dist/src/lib/ai/attachments.d.ts +30 -0
- package/dist/src/lib/ai/backend-url.d.ts +16 -0
- package/dist/src/lib/ai/cc-runtime.d.ts +61 -0
- package/dist/src/lib/ai/commit-gate.d.ts +21 -0
- package/dist/src/{ai → lib/ai}/context/builder.d.ts +6 -7
- package/dist/src/lib/ai/context/compression-middleware.d.ts +15 -0
- package/dist/src/lib/ai/context/compressor.d.ts +7 -0
- package/dist/src/lib/ai/context/intra-turn-compressor.d.ts +24 -0
- package/dist/src/lib/ai/context/optimize-thread-context.d.ts +9 -0
- package/dist/src/lib/ai/context/summarizer.d.ts +5 -0
- package/dist/src/{ai → lib/ai}/context/token-estimator.d.ts +5 -8
- package/dist/src/{ai → lib/ai}/context/types.d.ts +2 -0
- package/dist/src/{ai → lib/ai}/fetch.d.ts +1 -1
- package/dist/src/lib/ai/finish-turn.d.ts +23 -0
- package/dist/src/lib/ai/helpers.d.ts +38 -0
- package/dist/src/lib/ai/index.d.ts +7 -0
- package/dist/src/lib/ai/inspector.d.ts +41 -0
- package/dist/src/lib/ai/interceptors/request-interceptor.d.ts +13 -0
- package/dist/src/{ai → lib/ai}/prompt.d.ts +5 -4
- package/dist/src/lib/ai/redis-runs.d.ts +41 -0
- package/dist/src/lib/ai/redis-stream-log.d.ts +39 -0
- package/dist/src/lib/ai/tools/execute-code.tool.d.ts +18 -0
- package/dist/src/lib/ai/tools/query-graph.tool.d.ts +13 -0
- package/dist/src/lib/ai/tools/read-attachment.tool.d.ts +21 -0
- package/dist/src/lib/ai/tools/search-common.d.ts +17 -0
- package/dist/src/lib/ai/tools/search-knowledge.tool.d.ts +15 -0
- package/dist/src/lib/ai/tools/search-services.tool.d.ts +12 -0
- package/dist/src/lib/ai/tools/search-tools.tool.d.ts +12 -0
- package/dist/src/lib/ai/turn-tools.d.ts +39 -0
- package/dist/src/lib/ai/vision/rasterize-pdf.d.ts +26 -0
- package/dist/src/lib/ai/vision/rasterize-pdf.worker.d.ts +1 -0
- package/dist/src/lib/ai/vision/vision-reader.d.ts +45 -0
- package/dist/src/lib/cc/client.d.ts +183 -0
- package/dist/src/lib/cc/config-cache.d.ts +20 -0
- package/dist/src/lib/cc/format.d.ts +23 -0
- package/dist/src/lib/cc/registry.d.ts +34 -0
- package/dist/src/lib/cc/types.d.ts +27 -0
- package/dist/src/lib/config.d.ts +170 -0
- package/dist/src/lib/db/drizzle.config.mssql.d.ts +2 -0
- package/dist/src/lib/db/drizzle.config.pg.d.ts +2 -0
- package/dist/src/lib/db/migrate.d.ts +7 -0
- package/dist/src/{db/schema.d.ts → lib/db/schema.mssql.d.ts} +236 -24
- package/dist/src/lib/db/schema.pg.d.ts +546 -0
- package/dist/src/{factory.d.ts → lib/factory.d.ts} +2 -12
- package/dist/src/lib/graph/index.d.ts +10 -0
- package/dist/src/{graph → lib/graph}/resolver.d.ts +2 -3
- package/dist/src/{index.d.ts → lib/index.d.ts} +3 -4
- package/dist/src/lib/redis.d.ts +19 -0
- package/dist/src/{routes → lib/routes}/chat.d.ts +1 -1
- package/dist/src/{routes → lib/routes}/files.d.ts +1 -1
- package/dist/src/lib/routes/owned-thread.d.ts +17 -0
- package/dist/src/{routes → lib/routes}/threads.d.ts +1 -1
- package/dist/src/{routes → lib/routes}/ws.d.ts +1 -1
- package/dist/src/lib/types.d.ts +58 -0
- package/dist/src/{ws → lib/ws}/connections.d.ts +2 -1
- package/index.ts +1 -1
- package/package.json +35 -15
- package/src/lib/adapters/database/index.ts +78 -0
- package/src/lib/adapters/database/message-content.ts +39 -0
- package/src/lib/adapters/database/mssql/attachments.ts +90 -0
- package/src/lib/adapters/database/mssql/client.ts +11 -0
- package/src/lib/adapters/database/mssql/index.ts +23 -0
- package/src/lib/adapters/database/mssql/llm-requests.ts +43 -0
- package/src/lib/adapters/database/mssql/messages.ts +92 -0
- package/src/lib/adapters/database/mssql/threads.ts +83 -0
- package/src/lib/adapters/database/postgres/attachments.ts +88 -0
- package/src/lib/adapters/database/postgres/client.ts +11 -0
- package/src/lib/adapters/database/postgres/index.ts +23 -0
- package/src/lib/adapters/database/postgres/llm-requests.ts +45 -0
- package/src/lib/adapters/database/postgres/messages.ts +92 -0
- package/src/lib/adapters/database/postgres/threads.ts +84 -0
- package/{dist/src/adapters/storage.d.ts → src/lib/adapters/storage/index.ts} +1 -1
- package/src/{adapters → lib/adapters/storage}/minio.ts +7 -6
- package/src/lib/ai/active-runs.ts +157 -0
- package/src/lib/ai/attachments.ts +144 -0
- package/src/lib/ai/backend-url.ts +27 -0
- package/src/lib/ai/cc-runtime.ts +63 -0
- package/src/lib/ai/commit-gate.ts +116 -0
- package/src/{ai → lib/ai}/context/builder.ts +12 -14
- package/src/lib/ai/context/compression-middleware.ts +44 -0
- package/src/lib/ai/context/compressor.ts +267 -0
- package/src/lib/ai/context/intra-turn-compressor.ts +181 -0
- package/src/lib/ai/context/optimize-thread-context.ts +50 -0
- package/src/lib/ai/context/summarizer.ts +48 -0
- package/src/lib/ai/context/token-estimator.ts +65 -0
- package/src/{ai → lib/ai}/context/types.ts +4 -1
- package/src/{ai → lib/ai}/fetch.ts +10 -5
- package/src/lib/ai/finish-turn.ts +75 -0
- package/src/lib/ai/helpers.ts +100 -0
- package/src/lib/ai/index.ts +408 -0
- package/src/lib/ai/inspector.ts +112 -0
- package/src/lib/ai/interceptors/request-interceptor.ts +89 -0
- package/src/lib/ai/prompt.ts +355 -0
- package/src/lib/ai/redis-runs.ts +167 -0
- package/src/lib/ai/redis-stream-log.ts +219 -0
- package/src/lib/ai/tools/execute-code.tool.ts +267 -0
- package/src/{ai → lib/ai}/tools/query-graph.tool.ts +7 -8
- package/src/lib/ai/tools/read-attachment.tool.ts +59 -0
- package/src/lib/ai/tools/search-common.ts +15 -0
- package/src/lib/ai/tools/search-knowledge.tool.ts +33 -0
- package/src/lib/ai/tools/search-services.tool.ts +31 -0
- package/src/lib/ai/tools/search-tools.tool.ts +29 -0
- package/src/lib/ai/turn-tools.ts +154 -0
- package/src/lib/ai/vision/rasterize-pdf.ts +69 -0
- package/src/lib/ai/vision/rasterize-pdf.worker.ts +81 -0
- package/src/lib/ai/vision/vision-reader.ts +130 -0
- package/src/{auth → lib/auth}/middleware.ts +12 -3
- package/src/lib/cc/client.ts +273 -0
- package/src/lib/cc/config-cache.ts +32 -0
- package/src/lib/cc/format.ts +96 -0
- package/src/lib/cc/registry.ts +62 -0
- package/src/lib/cc/types.ts +44 -0
- package/src/lib/config.ts +212 -0
- package/src/lib/db/drizzle.config.mssql.ts +9 -0
- package/src/lib/db/drizzle.config.pg.ts +9 -0
- package/src/lib/db/migrate.ts +56 -0
- package/src/{db/migrations/20260309012148_cloudy_maria_hill → lib/db/migrations/mssql/20260326231647_nice_speedball}/migration.sql +12 -0
- package/src/{db/migrations/20260309012148_cloudy_maria_hill → lib/db/migrations/mssql/20260326231647_nice_speedball}/snapshot.json +106 -1
- package/src/lib/db/migrations/mssql/20260729092726_talented_wilson_fisk/migration.sql +20 -0
- package/src/lib/db/migrations/mssql/20260729092726_talented_wilson_fisk/snapshot.json +497 -0
- package/src/lib/db/migrations/mssql/20260801003652_perpetual_blue_shield/migration.sql +1 -0
- package/src/lib/db/migrations/mssql/20260801003652_perpetual_blue_shield/snapshot.json +516 -0
- package/src/lib/db/migrations/mssql/20260812174642_user_id_text/migration.sql +2 -0
- package/src/lib/db/migrations/mssql/20260812174642_user_id_text/snapshot.json +516 -0
- package/src/lib/db/migrations/pg/20260801084117_thick_ben_grimm/migration.sql +53 -0
- package/src/lib/db/migrations/pg/20260801084117_thick_ben_grimm/snapshot.json +584 -0
- package/src/lib/db/migrations/pg/20260812174622_user_id_text/migration.sql +2 -0
- package/src/lib/db/migrations/pg/20260812174622_user_id_text/snapshot.json +584 -0
- package/src/lib/db/schema.mssql.ts +94 -0
- package/src/lib/db/schema.pg.ts +95 -0
- package/src/lib/factory.ts +127 -0
- package/src/lib/graph/index.ts +19 -0
- package/src/lib/graph/resolver.ts +387 -0
- package/src/{index.ts → lib/index.ts} +13 -5
- package/src/lib/redis.ts +44 -0
- package/src/lib/routes/chat.ts +115 -0
- package/src/lib/routes/files.ts +129 -0
- package/src/lib/routes/owned-thread.ts +17 -0
- package/src/{routes → lib/routes}/threads.ts +22 -31
- package/src/{routes → lib/routes}/ws.ts +9 -3
- package/src/lib/types.ts +76 -0
- package/src/{ws → lib/ws}/connections.ts +9 -1
- package/tsconfig.json +48 -0
- package/dist/src/adapters/database.d.ts +0 -30
- package/dist/src/adapters/mssql.d.ts +0 -3
- package/dist/src/ai/active-streams.d.ts +0 -14
- package/dist/src/ai/context/compressor.d.ts +0 -7
- package/dist/src/ai/context/index.d.ts +0 -15
- package/dist/src/ai/context/summarizer.d.ts +0 -5
- package/dist/src/ai/helpers.d.ts +0 -5
- package/dist/src/ai/index.d.ts +0 -4
- package/dist/src/ai/interceptors/request-interceptor.d.ts +0 -12
- package/dist/src/ai/tools/call-endpoint.tool.d.ts +0 -7
- package/dist/src/ai/tools/capture-files.tool.d.ts +0 -6
- package/dist/src/ai/tools/execute-code.tool.d.ts +0 -4
- package/dist/src/ai/tools/query-graph.tool.d.ts +0 -5
- package/dist/src/cli/extract-endpoints.d.ts +0 -6
- package/dist/src/config.d.ts +0 -165
- package/dist/src/db/migrate.d.ts +0 -1
- package/dist/src/graph/expand-domains.d.ts +0 -2
- package/dist/src/graph/generate-cypher.d.ts +0 -22
- package/dist/src/graph/index.d.ts +0 -11
- package/dist/src/graph/seed.d.ts +0 -19
- package/dist/src/graph/validate.d.ts +0 -2
- package/dist/src/routes/index.d.ts +0 -4
- package/dist/src/types.d.ts +0 -74
- package/dist/src/ws/events.d.ts +0 -39
- package/dist/src/ws/index.d.ts +0 -3
- package/dist/src/ws/notify.d.ts +0 -2
- package/src/adapters/database.ts +0 -29
- package/src/adapters/mssql.ts +0 -195
- package/src/ai/active-streams.ts +0 -123
- package/src/ai/context/compressor.ts +0 -47
- package/src/ai/context/index.ts +0 -75
- package/src/ai/context/summarizer.ts +0 -50
- package/src/ai/context/token-estimator.ts +0 -60
- package/src/ai/helpers.ts +0 -32
- package/src/ai/index.ts +0 -277
- package/src/ai/interceptors/request-interceptor.ts +0 -61
- package/src/ai/prompt.ts +0 -126
- package/src/ai/tools/call-endpoint.tool.ts +0 -89
- package/src/ai/tools/capture-files.tool.ts +0 -20
- package/src/ai/tools/execute-code.tool.ts +0 -153
- package/src/cli/extract-endpoints.ts +0 -550
- package/src/config.ts +0 -182
- package/src/db/migrate.ts +0 -21
- package/src/db/migrations/20260315000000_add_context_meta/migration.sql +0 -1
- package/src/db/schema.ts +0 -70
- package/src/factory.ts +0 -171
- package/src/graph/expand-domains.ts +0 -276
- package/src/graph/generate-cypher.ts +0 -192
- package/src/graph/index.ts +0 -47
- package/src/graph/resolver.ts +0 -357
- package/src/graph/seed.ts +0 -162
- package/src/graph/validate.ts +0 -78
- package/src/routes/chat.ts +0 -86
- package/src/routes/files.ts +0 -100
- package/src/routes/index.ts +0 -4
- package/src/types.ts +0 -74
- package/src/ws/events.ts +0 -39
- package/src/ws/index.ts +0 -11
- package/src/ws/notify.ts +0 -9
- /package/dist/src/{auth → lib/auth}/middleware.d.ts +0 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { DatabaseAdapter } from "@/adapters/database/index";
|
|
2
|
+
import type { StorageAdapter } from "@/adapters/storage/index";
|
|
3
|
+
import { streamToBase64 } from "@/ai/helpers";
|
|
4
|
+
|
|
5
|
+
export type ResolvedFile = {
|
|
6
|
+
name: string;
|
|
7
|
+
bytes: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type RequestInterceptorOptions = {
|
|
11
|
+
transformFile?: (file: ResolvedFile) => unknown;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function createRequestInterceptor(
|
|
15
|
+
db: DatabaseAdapter,
|
|
16
|
+
storage: StorageAdapter,
|
|
17
|
+
options: RequestInterceptorOptions & { threadId: string; userId: string },
|
|
18
|
+
) {
|
|
19
|
+
const transformFile = options.transformFile ?? ((file: ResolvedFile) => file);
|
|
20
|
+
|
|
21
|
+
return async function resolveRequestBody(body: Record<string, unknown>) {
|
|
22
|
+
const resolvedAttachments = new Map<string, { value: unknown }>();
|
|
23
|
+
const pending = Object.entries(body).map(([key, value]) => ({
|
|
24
|
+
value,
|
|
25
|
+
replace: (resolved: unknown) => {
|
|
26
|
+
body[key] = resolved;
|
|
27
|
+
},
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
async function resolveAttachment(id: string) {
|
|
31
|
+
const cached = resolvedAttachments.get(id);
|
|
32
|
+
if (cached) return cached;
|
|
33
|
+
|
|
34
|
+
const attachment = await db.attachments.getById(id, options.userId);
|
|
35
|
+
if (!attachment || attachment.threadId !== options.threadId) {
|
|
36
|
+
throw new Error(`Attachment "${id}" is not available in this thread`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let bytes: string;
|
|
40
|
+
try {
|
|
41
|
+
bytes = await streamToBase64(await storage.stream(attachment.storageKey));
|
|
42
|
+
} catch (error) {
|
|
43
|
+
throw new Error(`Attachment "${id}" could not be loaded from storage`, {
|
|
44
|
+
cause: error,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
const resolved = { value: transformFile({ name: attachment.filename, bytes }) };
|
|
48
|
+
resolvedAttachments.set(id, resolved);
|
|
49
|
+
return resolved;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
while (pending.length) {
|
|
53
|
+
const { value, replace } = pending.pop()!;
|
|
54
|
+
|
|
55
|
+
if (Array.isArray(value)) {
|
|
56
|
+
const items: unknown[] = value;
|
|
57
|
+
pending.push(
|
|
58
|
+
...items.map((child, index) => ({
|
|
59
|
+
value: child,
|
|
60
|
+
replace: (resolved: unknown) => {
|
|
61
|
+
items[index] = resolved;
|
|
62
|
+
},
|
|
63
|
+
})),
|
|
64
|
+
);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (typeof value === "object" && value !== null) {
|
|
69
|
+
const object = value as Record<string, unknown>;
|
|
70
|
+
pending.push(
|
|
71
|
+
...Object.entries(object).map(([key, child]) => ({
|
|
72
|
+
value: child,
|
|
73
|
+
replace: (resolved: unknown) => {
|
|
74
|
+
object[key] = resolved;
|
|
75
|
+
},
|
|
76
|
+
})),
|
|
77
|
+
);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (typeof value !== "string" || !value.startsWith("attachment#")) continue;
|
|
82
|
+
|
|
83
|
+
const resolved = await resolveAttachment(value.slice("attachment#".length));
|
|
84
|
+
replace(resolved.value);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return body;
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import type { ResolvedContext, ResolvedEndpoint, ResolvedService } from "@/graph/resolver";
|
|
2
|
+
import type { PromptContext, ResolvedCortexAgentConfig } from "@/config";
|
|
3
|
+
import type { Thread } from "@/types";
|
|
4
|
+
import type { CcPromptInput } from "@/cc/format";
|
|
5
|
+
import { buildCcSection } from "@/cc/format";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolves session data for the thread, loading from the configured
|
|
9
|
+
* session loader if not already cached on the thread.
|
|
10
|
+
*/
|
|
11
|
+
export async function resolveSession(
|
|
12
|
+
config: ResolvedCortexAgentConfig,
|
|
13
|
+
thread: Thread,
|
|
14
|
+
token: string,
|
|
15
|
+
) {
|
|
16
|
+
let session = thread.session;
|
|
17
|
+
|
|
18
|
+
if (!session && config.loadSessionData) {
|
|
19
|
+
session = await config.loadSessionData(token);
|
|
20
|
+
// Persist to DB for future cache hits
|
|
21
|
+
await config.db.threads.updateSession(thread.id, session);
|
|
22
|
+
thread.session = session;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return session;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function buildSystemPrompt(
|
|
29
|
+
config: ResolvedCortexAgentConfig,
|
|
30
|
+
resolved: ResolvedContext | null,
|
|
31
|
+
promptContext: PromptContext,
|
|
32
|
+
cc?: CcPromptInput,
|
|
33
|
+
attachmentListing?: string,
|
|
34
|
+
) {
|
|
35
|
+
// Resolve the consumer's base system prompt
|
|
36
|
+
let basePrompt: string;
|
|
37
|
+
if (typeof config.systemPrompt === "function") {
|
|
38
|
+
basePrompt = await config.systemPrompt(promptContext);
|
|
39
|
+
} else {
|
|
40
|
+
basePrompt = config.systemPrompt ?? "";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const sections: string[] = basePrompt ? [basePrompt] : [];
|
|
44
|
+
|
|
45
|
+
// Pre-resolved endpoints from knowledge graph
|
|
46
|
+
if (resolved) {
|
|
47
|
+
sections.push(buildResolvedSection(resolved));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Control Center: published prompt + per-turn resolved catalog section
|
|
51
|
+
if (cc) {
|
|
52
|
+
const ccSection = buildCcSection(cc);
|
|
53
|
+
if (ccSection) sections.push(ccSection);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (attachmentListing) {
|
|
57
|
+
sections.push(attachmentListing);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return sections.join("\n");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type SchemaField = {
|
|
64
|
+
name: string;
|
|
65
|
+
required?: boolean;
|
|
66
|
+
type?: string;
|
|
67
|
+
isArray?: boolean;
|
|
68
|
+
properties?: SchemaField[];
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** Compression layer 2. Compact param/body: `name?: type, nested?: {a: string, b: number}[]` */
|
|
72
|
+
function compactParamFields(fields: SchemaField[]): string {
|
|
73
|
+
return fields
|
|
74
|
+
.map((f) => {
|
|
75
|
+
const opt = f.required ? "" : "?";
|
|
76
|
+
const arr = f.isArray ? "[]" : "";
|
|
77
|
+
if (f.properties && f.properties.length > 0) {
|
|
78
|
+
return `${f.name}${opt}: {${compactParamFields(f.properties)}}${arr}`;
|
|
79
|
+
}
|
|
80
|
+
return `${f.name}${opt}: ${f.type ?? "unknown"}${arr}`;
|
|
81
|
+
})
|
|
82
|
+
.join(", ");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function compactParams(jsonStr: string) {
|
|
86
|
+
try {
|
|
87
|
+
const fields = JSON.parse(jsonStr) as SchemaField[];
|
|
88
|
+
if (!Array.isArray(fields) || fields.length === 0) return "(none)";
|
|
89
|
+
return compactParamFields(fields);
|
|
90
|
+
} catch {
|
|
91
|
+
return jsonStr;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Compact response: `items[].{id, employee.{id, name.{ar, en}}}, count` */
|
|
96
|
+
function compactResponseFields(fields: SchemaField[]): string {
|
|
97
|
+
return fields
|
|
98
|
+
.map((f) => {
|
|
99
|
+
const arr = f.isArray ? "[]" : "";
|
|
100
|
+
if (f.properties && f.properties.length > 0) {
|
|
101
|
+
return `${f.name}${arr}.{${compactResponseFields(f.properties)}}`;
|
|
102
|
+
}
|
|
103
|
+
return f.name;
|
|
104
|
+
})
|
|
105
|
+
.join(", ");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function compactResponse(jsonStr: string) {
|
|
109
|
+
try {
|
|
110
|
+
const fields = JSON.parse(jsonStr) as SchemaField[];
|
|
111
|
+
if (!Array.isArray(fields) || fields.length === 0) return "(none)";
|
|
112
|
+
return compactResponseFields(fields);
|
|
113
|
+
} catch {
|
|
114
|
+
return jsonStr;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Compression layer 1. Endpoints reached via several concepts arrive as
|
|
120
|
+
* duplicates sharing one method+path; collapsing them first means every later
|
|
121
|
+
* layer works on one entry per real endpoint.
|
|
122
|
+
*/
|
|
123
|
+
type GroupedEndpoint = Omit<ResolvedEndpoint, "concept"> & {
|
|
124
|
+
concepts: string[];
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
function addUnique<T>(list: T[], value: T, matches: (a: T, b: T) => boolean = Object.is) {
|
|
128
|
+
if (!list.some((item) => matches(item, value))) {
|
|
129
|
+
list.push(value);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Folds a second endpoint sharing this method and path into the group already collected. */
|
|
134
|
+
function mergeEndpoint(existing: GroupedEndpoint, ep: ResolvedEndpoint) {
|
|
135
|
+
addUnique(existing.concepts, ep.concept);
|
|
136
|
+
|
|
137
|
+
for (const rule of ep.rules) {
|
|
138
|
+
addUnique(existing.rules, rule);
|
|
139
|
+
}
|
|
140
|
+
for (const dep of ep.dependencies) {
|
|
141
|
+
addUnique(
|
|
142
|
+
existing.dependencies,
|
|
143
|
+
dep,
|
|
144
|
+
(a, b) => a.depPath === b.depPath && a.paramName === b.paramName,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The arrays are copied because merging mutates them, and the source endpoint is shared. */
|
|
150
|
+
function toGroup(ep: ResolvedEndpoint) {
|
|
151
|
+
const { concept, ...rest } = ep;
|
|
152
|
+
return {
|
|
153
|
+
...rest,
|
|
154
|
+
concepts: [concept],
|
|
155
|
+
dependencies: [...ep.dependencies],
|
|
156
|
+
rules: [...ep.rules],
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function groupEndpointsBySignature(endpoints: ResolvedEndpoint[]) {
|
|
161
|
+
const groups = new Map<string, GroupedEndpoint>();
|
|
162
|
+
|
|
163
|
+
for (const ep of endpoints) {
|
|
164
|
+
const key = `${ep.method}|${ep.path}`;
|
|
165
|
+
const existing = groups.get(key);
|
|
166
|
+
|
|
167
|
+
if (existing) {
|
|
168
|
+
mergeEndpoint(existing, ep);
|
|
169
|
+
} else {
|
|
170
|
+
groups.set(key, toGroup(ep));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return Array.from(groups.values());
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Compression layer 3. Hoists rules repeated across endpoints into one global
|
|
179
|
+
* section, so a rule governing twenty endpoints is stated once, not twenty times.
|
|
180
|
+
*/
|
|
181
|
+
function extractSharedRules(groups: GroupedEndpoint[], services: ResolvedService[]) {
|
|
182
|
+
const ruleCounts = new Map<string, number>();
|
|
183
|
+
|
|
184
|
+
for (const ep of groups) {
|
|
185
|
+
for (const rule of ep.rules) {
|
|
186
|
+
ruleCounts.set(rule, (ruleCounts.get(rule) ?? 0) + 1);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
for (const svc of services) {
|
|
190
|
+
for (const rule of svc.rules) {
|
|
191
|
+
ruleCounts.set(rule, (ruleCounts.get(rule) ?? 0) + 1);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const sharedRules = [...ruleCounts.entries()]
|
|
196
|
+
.filter(([, count]) => count >= 2)
|
|
197
|
+
.map(([rule]) => rule);
|
|
198
|
+
|
|
199
|
+
const sharedSet = new Set(sharedRules);
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
sharedRules,
|
|
203
|
+
groups: groups.map((ep) => ({
|
|
204
|
+
...ep,
|
|
205
|
+
rules: ep.rules.filter((r) => !sharedSet.has(r)),
|
|
206
|
+
})),
|
|
207
|
+
services: services.map((svc) => ({
|
|
208
|
+
...svc,
|
|
209
|
+
rules: svc.rules.filter((r) => !sharedSet.has(r)),
|
|
210
|
+
})),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Compression layer 4. Endpoints returning an identical response schema get one
|
|
216
|
+
* named shape definition and a reference each, instead of repeating the schema.
|
|
217
|
+
*/
|
|
218
|
+
type ResponseShapeRefs = {
|
|
219
|
+
shapeDefs: { name: string; content: string }[];
|
|
220
|
+
refMap: Map<string, string>;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
function buildResponseShapeRefs(groups: GroupedEndpoint[]) {
|
|
224
|
+
// Key on the raw JSON string (byte-identical for endpoints sharing the same
|
|
225
|
+
// Endpoint node in Neo4j). Fall back to the compact string for cases where
|
|
226
|
+
// different raw JSON produces the same compact shape.
|
|
227
|
+
const rawToEndpoints = new Map<string, string[]>();
|
|
228
|
+
|
|
229
|
+
for (const ep of groups) {
|
|
230
|
+
if (ep.response === "[]") continue;
|
|
231
|
+
const existing = rawToEndpoints.get(ep.response);
|
|
232
|
+
if (existing) {
|
|
233
|
+
existing.push(`${ep.method} ${ep.path}`);
|
|
234
|
+
} else {
|
|
235
|
+
rawToEndpoints.set(ep.response, [`${ep.method} ${ep.path}`]);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Second pass: also group by compact output to catch different raw JSON
|
|
240
|
+
// that normalizes to the same shape
|
|
241
|
+
const compactToRaws = new Map<string, Set<string>>();
|
|
242
|
+
for (const raw of rawToEndpoints.keys()) {
|
|
243
|
+
const compact = compactResponse(raw);
|
|
244
|
+
const existing = compactToRaws.get(compact);
|
|
245
|
+
if (existing) {
|
|
246
|
+
existing.add(raw);
|
|
247
|
+
} else {
|
|
248
|
+
compactToRaws.set(compact, new Set([raw]));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Merge: a shape is shared if its raw string appears on 2+ endpoints,
|
|
253
|
+
// OR if multiple raw strings compact to the same output
|
|
254
|
+
const shapeDefs: { name: string; content: string }[] = [];
|
|
255
|
+
const refMap = new Map<string, string>();
|
|
256
|
+
let idx = 1;
|
|
257
|
+
|
|
258
|
+
for (const [compact, raws] of compactToRaws) {
|
|
259
|
+
let totalEndpoints = 0;
|
|
260
|
+
for (const raw of raws) {
|
|
261
|
+
totalEndpoints += rawToEndpoints.get(raw)!.length;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (totalEndpoints >= 2) {
|
|
265
|
+
const name = `$R${idx++}`;
|
|
266
|
+
shapeDefs.push({ name, content: compact });
|
|
267
|
+
refMap.set(compact, name);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return { shapeDefs, refMap };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** A heading and its lines, or nothing at all when there are no lines to show. */
|
|
275
|
+
function renderSection(heading: string, lines: string[]) {
|
|
276
|
+
if (lines.length === 0) return [];
|
|
277
|
+
return [`\n### ${heading}`, ...lines.map((line) => `\n${line}`)];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function renderEndpoint(ep: GroupedEndpoint, refMap: ResponseShapeRefs["refMap"]) {
|
|
281
|
+
const compactResp = compactResponse(ep.response);
|
|
282
|
+
const body = compactParams(ep.body);
|
|
283
|
+
|
|
284
|
+
const lines = [
|
|
285
|
+
`\n### ${ep.name} — ${ep.method} ${ep.path} (${ep.relation})`,
|
|
286
|
+
`\n- Concepts: ${ep.concepts.join(", ")}`,
|
|
287
|
+
`\n- Params: ${compactParams(ep.params)}`,
|
|
288
|
+
body === "(none)" ? "" : `\n- Body: ${body}`,
|
|
289
|
+
`\n- Response: ${refMap.get(compactResp) ?? compactResp}`,
|
|
290
|
+
ep.rules.length > 0 ? `\n Rules: ${ep.rules.join("; ")}` : "",
|
|
291
|
+
renderDependencies(ep.dependencies),
|
|
292
|
+
ep.metadata === "{}" ? "" : `\n Metadata: ${ep.metadata}`,
|
|
293
|
+
];
|
|
294
|
+
|
|
295
|
+
return lines.join("");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function renderDependencies(dependencies: GroupedEndpoint["dependencies"]) {
|
|
299
|
+
if (dependencies.length === 0) return "";
|
|
300
|
+
|
|
301
|
+
const calls = dependencies.map(
|
|
302
|
+
(d) =>
|
|
303
|
+
` - Call ${d.depMethod} ${d.depPath} first → use its "${d.fromField}" as "${d.paramName}"`,
|
|
304
|
+
);
|
|
305
|
+
return `\n Dependencies:\n${calls.join("\n")}`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function renderService(svc: ResolvedService) {
|
|
309
|
+
const rules = svc.rules.length > 0 ? `\n Rules: ${svc.rules.join("; ")}` : "";
|
|
310
|
+
const meta = svc.metadata === "{}" ? "" : `\n- Metadata: ${svc.metadata}`;
|
|
311
|
+
|
|
312
|
+
return (
|
|
313
|
+
`\n### ${svc.concept} via ${svc.serviceName} (service)` +
|
|
314
|
+
`\n- Built-in ID: ${svc.builtInId}` +
|
|
315
|
+
`\n- Description: ${svc.description || "N/A"}${rules}${meta}`
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function buildResolvedSection(resolved: ResolvedContext) {
|
|
320
|
+
const allEndpoints = [...resolved.readEndpoints, ...resolved.writeEndpoints];
|
|
321
|
+
|
|
322
|
+
if (allEndpoints.length === 0 && resolved.services.length === 0) {
|
|
323
|
+
return "\n## Pre-resolved API Endpoints\nNo matching endpoints found. Use queryGraph to search the knowledge graph manually.";
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Layer 1: deduplicate endpoints by method+path
|
|
327
|
+
const grouped = groupEndpointsBySignature(allEndpoints);
|
|
328
|
+
|
|
329
|
+
// Layer 3: hoist repeated rules to a shared section
|
|
330
|
+
const {
|
|
331
|
+
sharedRules,
|
|
332
|
+
groups: cleanedGroups,
|
|
333
|
+
services: cleanedServices,
|
|
334
|
+
} = extractSharedRules(grouped, resolved.services);
|
|
335
|
+
|
|
336
|
+
// Layer 4: deduplicate identical response schemas
|
|
337
|
+
const { shapeDefs, refMap } = buildResponseShapeRefs(cleanedGroups);
|
|
338
|
+
|
|
339
|
+
return [
|
|
340
|
+
`
|
|
341
|
+
## Pre-resolved API Endpoints
|
|
342
|
+
The following endpoints were automatically matched to the user's message.`,
|
|
343
|
+
...renderSection(
|
|
344
|
+
"General Rules",
|
|
345
|
+
sharedRules.map((rule) => `- ${rule}`),
|
|
346
|
+
),
|
|
347
|
+
...renderSection(
|
|
348
|
+
"Response Shapes",
|
|
349
|
+
shapeDefs.map((def) => `${def.name}: ${def.content}`),
|
|
350
|
+
),
|
|
351
|
+
// Layer 2: compact schemas
|
|
352
|
+
...cleanedGroups.map((ep) => renderEndpoint(ep, refMap)),
|
|
353
|
+
...cleanedServices.map(renderService),
|
|
354
|
+
].join("");
|
|
355
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type { RedisConnections } from "@/redis";
|
|
2
|
+
import type { ActiveRun, RunCoordinator } from "./active-runs";
|
|
3
|
+
import { JOIN_FIRST_CHUNK_TIMEOUT_MS } from "./active-runs";
|
|
4
|
+
import { offsetBelongsToRun, redisStreamLog } from "./redis-stream-log";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The claim is the producer's liveness signal: a heartbeat refreshes its short
|
|
8
|
+
* TTL while the run is live, so a claim that expired means the producer died —
|
|
9
|
+
* that is how joiners on an orphaned run learn to stop tailing, and how a
|
|
10
|
+
* crashed instance's threads free up within a minute instead of fifteen. The
|
|
11
|
+
* stream TTL governs how long a run's chunk log stays replayable.
|
|
12
|
+
*/
|
|
13
|
+
const CLAIM_TTL_SECONDS = 60;
|
|
14
|
+
const CLAIM_HEARTBEAT_MS = 20_000;
|
|
15
|
+
const DEFAULT_STREAM_TTL_SECONDS = 24 * 60 * 60;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Cross-instance abort: the producer's AbortController lives in-process on
|
|
19
|
+
* whichever instance runs the turn, but a stop may land on any instance — so
|
|
20
|
+
* abort publishes the run id and every instance listens, aborting the run if
|
|
21
|
+
* it owns the controller. Single-instance works too: the publisher's own
|
|
22
|
+
* subscriber receives the message.
|
|
23
|
+
*/
|
|
24
|
+
const ABORT_CHANNEL = "cortex:chat:abort";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The claim doubles as mutex and discovery pointer: a joiner that only knows
|
|
28
|
+
* the thread id finds the live run here.
|
|
29
|
+
*/
|
|
30
|
+
function claimKey(threadId: string) {
|
|
31
|
+
return `cortex:chat:active:${threadId}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Atomically take the claim and learn who held it. Two concurrent starts on
|
|
36
|
+
* one thread each see the other exactly once, so the loser is always aborted —
|
|
37
|
+
* a GET-then-SET pair could interleave and leave both producers running.
|
|
38
|
+
*/
|
|
39
|
+
const CLAIM_SWAP = `local previous = redis.call("get", KEYS[1])
|
|
40
|
+
redis.call("set", KEYS[1], ARGV[1], "EX", ARGV[2])
|
|
41
|
+
return previous`;
|
|
42
|
+
|
|
43
|
+
/** Release only if still held by this run — a superseded run finishing late
|
|
44
|
+
* must not evict its replacement. GET-then-DEL would race; the script cannot. */
|
|
45
|
+
const RELEASE_IF_HELD = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`;
|
|
46
|
+
|
|
47
|
+
/** The heartbeat must never resurrect a claim that abort or supersede released. */
|
|
48
|
+
const REFRESH_IF_HELD = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("expire", KEYS[1], ARGV[2]) else return 0 end`;
|
|
49
|
+
|
|
50
|
+
type RedisRunOptions = {
|
|
51
|
+
streamTtlSeconds?: number;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export function createRedisRuns(connections: RedisConnections, options: RedisRunOptions = {}) {
|
|
55
|
+
const { publisher, subscriber } = connections;
|
|
56
|
+
const streamTtlMs = (options.streamTtlSeconds ?? DEFAULT_STREAM_TTL_SECONDS) * 1000;
|
|
57
|
+
const logOptions = { streamTtlMs, firstChunkDeadlineMs: JOIN_FIRST_CHUNK_TIMEOUT_MS };
|
|
58
|
+
const aborters = new Map<string, AbortController>();
|
|
59
|
+
const heartbeats = new Map<string, ReturnType<typeof setInterval>>();
|
|
60
|
+
|
|
61
|
+
subscriber.on("message", function (channel: string, runId: string) {
|
|
62
|
+
if (channel === ABORT_CHANNEL) aborters.get(runId)?.abort();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Lazy and retried, never memoized-rejected: caching one boot-time
|
|
67
|
+
* subscribe promise would leave every future startRun rethrowing a single
|
|
68
|
+
* transient outage forever. Once it resolves, ioredis re-subscribes by
|
|
69
|
+
* itself across reconnects.
|
|
70
|
+
*/
|
|
71
|
+
let abortSubscription: Promise<unknown> | undefined;
|
|
72
|
+
function ensureAbortSubscription() {
|
|
73
|
+
abortSubscription ??= subscriber.subscribe(ABORT_CHANNEL).catch((error: unknown) => {
|
|
74
|
+
abortSubscription = undefined;
|
|
75
|
+
throw error;
|
|
76
|
+
});
|
|
77
|
+
return abortSubscription;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function abortRun(threadId: string) {
|
|
81
|
+
const runId = await publisher.get(claimKey(threadId));
|
|
82
|
+
if (!runId) return false;
|
|
83
|
+
// The local abort is immediate; the publish reaches the other instances.
|
|
84
|
+
aborters.get(runId)?.abort();
|
|
85
|
+
await publisher.publish(ABORT_CHANNEL, runId);
|
|
86
|
+
await publisher.eval(RELEASE_IF_HELD, 1, claimKey(threadId), runId);
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function startHeartbeat(threadId: string, runId: string) {
|
|
91
|
+
const timer = setInterval(function () {
|
|
92
|
+
void publisher
|
|
93
|
+
.eval(REFRESH_IF_HELD, 1, claimKey(threadId), runId, String(CLAIM_TTL_SECONDS))
|
|
94
|
+
.catch((error: unknown) =>
|
|
95
|
+
console.error("[cortex-server] redis claim heartbeat:", error),
|
|
96
|
+
);
|
|
97
|
+
}, CLAIM_HEARTBEAT_MS);
|
|
98
|
+
heartbeats.set(runId, timer);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function stopHeartbeat(runId: string) {
|
|
102
|
+
const timer = heartbeats.get(runId);
|
|
103
|
+
if (timer !== undefined) clearInterval(timer);
|
|
104
|
+
heartbeats.delete(runId);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
async startRun(threadId) {
|
|
109
|
+
// A producer must be able to hear aborts before it may exist, or a
|
|
110
|
+
// stop from another instance could slip by while we boot.
|
|
111
|
+
await ensureAbortSubscription();
|
|
112
|
+
const run: ActiveRun = {
|
|
113
|
+
runId: crypto.randomUUID(),
|
|
114
|
+
abortController: new AbortController(),
|
|
115
|
+
};
|
|
116
|
+
// Registered before the claim exists, so an abort cannot land in
|
|
117
|
+
// between; unregistered again if the claim never materializes.
|
|
118
|
+
aborters.set(run.runId, run.abortController);
|
|
119
|
+
try {
|
|
120
|
+
const superseded = (await publisher.eval(
|
|
121
|
+
CLAIM_SWAP,
|
|
122
|
+
1,
|
|
123
|
+
claimKey(threadId),
|
|
124
|
+
run.runId,
|
|
125
|
+
String(CLAIM_TTL_SECONDS),
|
|
126
|
+
)) as string | null;
|
|
127
|
+
if (superseded !== null) {
|
|
128
|
+
aborters.get(superseded)?.abort();
|
|
129
|
+
await publisher.publish(ABORT_CHANNEL, superseded);
|
|
130
|
+
}
|
|
131
|
+
} catch (error) {
|
|
132
|
+
aborters.delete(run.runId);
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
startHeartbeat(threadId, run.runId);
|
|
136
|
+
return run;
|
|
137
|
+
},
|
|
138
|
+
async getRunId(threadId) {
|
|
139
|
+
return (await publisher.get(claimKey(threadId))) ?? undefined;
|
|
140
|
+
},
|
|
141
|
+
async runningThreadIds(threadIds) {
|
|
142
|
+
if (threadIds.length === 0) return new Set<string>();
|
|
143
|
+
const claims = await publisher.mget(threadIds.map(claimKey));
|
|
144
|
+
return new Set(threadIds.filter((_, index) => claims[index] !== null));
|
|
145
|
+
},
|
|
146
|
+
abortRun,
|
|
147
|
+
async endRun(threadId, runId) {
|
|
148
|
+
stopHeartbeat(runId);
|
|
149
|
+
aborters.delete(runId);
|
|
150
|
+
await publisher.eval(RELEASE_IF_HELD, 1, claimKey(threadId), runId);
|
|
151
|
+
},
|
|
152
|
+
producerLog(runId) {
|
|
153
|
+
return redisStreamLog(publisher, runId, { resumeOffset: null, ...logOptions });
|
|
154
|
+
},
|
|
155
|
+
joinLog(threadId, runId, offset = "-1") {
|
|
156
|
+
return redisStreamLog(publisher, runId, {
|
|
157
|
+
resumeOffset: offset,
|
|
158
|
+
// The claim is the producer's pulse: while this run holds it the
|
|
159
|
+
// reader may park between chunks indefinitely, and once it is
|
|
160
|
+
// gone with the log still unclosed, the producer died.
|
|
161
|
+
isLive: async () => (await publisher.get(claimKey(threadId))) === runId,
|
|
162
|
+
...logOptions,
|
|
163
|
+
});
|
|
164
|
+
},
|
|
165
|
+
offsetBelongsToRun,
|
|
166
|
+
} satisfies RunCoordinator;
|
|
167
|
+
}
|