@rynx-ai/daemon 0.1.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-file.d.ts +13 -0
- package/dist/agent-file.js +61 -0
- package/dist/channel-store.d.ts +28 -0
- package/dist/channel-store.js +81 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +284 -0
- package/dist/control-deps.d.ts +2 -0
- package/dist/control-deps.js +115 -0
- package/dist/daemon-server.d.ts +7 -0
- package/dist/daemon-server.js +90 -0
- package/dist/db.d.ts +5 -0
- package/dist/db.js +170 -0
- package/dist/index-daemon.d.ts +2 -0
- package/dist/index-daemon.js +20 -0
- package/dist/instance-store.d.ts +38 -0
- package/dist/instance-store.js +85 -0
- package/dist/migrations/cleanup-legacy-sessions.d.ts +18 -0
- package/dist/migrations/cleanup-legacy-sessions.js +123 -0
- package/dist/plugin-installer.d.ts +27 -0
- package/dist/plugin-installer.js +186 -0
- package/dist/plugin-store.d.ts +27 -0
- package/dist/plugin-store.js +56 -0
- package/dist/pm2.d.ts +19 -0
- package/dist/pm2.js +153 -0
- package/dist/registry.d.ts +23 -0
- package/dist/registry.js +117 -0
- package/dist/session-log-store.d.ts +22 -0
- package/dist/session-log-store.js +91 -0
- package/dist/session-meta-store.d.ts +24 -0
- package/dist/session-meta-store.js +59 -0
- package/dist/setup.d.ts +4 -0
- package/dist/setup.js +235 -0
- package/dist/skills-catalog.d.ts +18 -0
- package/dist/skills-catalog.js +94 -0
- package/dist/stdio-epipe-guard.d.ts +8 -0
- package/dist/stdio-epipe-guard.js +16 -0
- package/dist/update.d.ts +16 -0
- package/dist/update.js +160 -0
- package/package.json +37 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared daemon composition root.
|
|
3
|
+
*
|
|
4
|
+
* The CLI/pm2 entry and the Electron app both need the same Rynx server:
|
|
5
|
+
* channel env hydration, plugin catalog loading, control deps, session log, and
|
|
6
|
+
* session registry. Keeping that wiring here avoids a second app-specific fork.
|
|
7
|
+
*/
|
|
8
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { loadConfig, rynxHome } from "@rynx-ai/core";
|
|
11
|
+
import { startServer } from "@rynx-ai/server";
|
|
12
|
+
import { applyChannelEnv } from "./channel-store.js";
|
|
13
|
+
import { buildControlDeps } from "./control-deps.js";
|
|
14
|
+
import { cleanupLegacySessions } from "./migrations/cleanup-legacy-sessions.js";
|
|
15
|
+
import { loadPlugins, resolveChannelInstances } from "./registry.js";
|
|
16
|
+
import { SqliteSessionLogStore } from "./session-log-store.js";
|
|
17
|
+
import { sessionRegistry } from "./session-meta-store.js";
|
|
18
|
+
function defaultWarn(message) {
|
|
19
|
+
console.warn(JSON.stringify({ level: "warn", type: "channel-registry", msg: message }));
|
|
20
|
+
}
|
|
21
|
+
function logLegacyCleanup(config) {
|
|
22
|
+
try {
|
|
23
|
+
const result = cleanupLegacySessions({ codexStorePath: config.AGENT_SESSION_STORE_PATH });
|
|
24
|
+
if (result.deletedSessions || result.deletedLogRows || result.prunedStoreEntries) {
|
|
25
|
+
console.log(JSON.stringify({
|
|
26
|
+
level: "info",
|
|
27
|
+
type: "cleanup",
|
|
28
|
+
msg: "legacy sessions cleaned up",
|
|
29
|
+
deletedSessions: result.deletedSessions,
|
|
30
|
+
deletedLogRows: result.deletedLogRows,
|
|
31
|
+
prunedStoreEntries: result.prunedStoreEntries,
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
console.error(JSON.stringify({
|
|
37
|
+
level: "error",
|
|
38
|
+
type: "cleanup",
|
|
39
|
+
msg: "legacy session cleanup failed (retry next boot)",
|
|
40
|
+
error: error instanceof Error ? error.message : String(error),
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function dialableHost(host) {
|
|
45
|
+
return host === "0.0.0.0" || host === "::" || host.trim() === "" ? "127.0.0.1" : host;
|
|
46
|
+
}
|
|
47
|
+
function controlEndpointFile() {
|
|
48
|
+
return join(rynxHome(), "control.json");
|
|
49
|
+
}
|
|
50
|
+
async function writeControlEndpoint(config) {
|
|
51
|
+
const file = controlEndpointFile();
|
|
52
|
+
await mkdir(dirname(file), { recursive: true });
|
|
53
|
+
await writeFile(file, `${JSON.stringify({
|
|
54
|
+
origin: `http://${dialableHost(config.HOST)}:${config.PORT}`,
|
|
55
|
+
host: config.HOST,
|
|
56
|
+
port: config.PORT,
|
|
57
|
+
pid: process.pid,
|
|
58
|
+
updatedAt: new Date().toISOString(),
|
|
59
|
+
}, null, 2)}\n`, "utf8");
|
|
60
|
+
}
|
|
61
|
+
async function removeControlEndpointForCurrentProcess() {
|
|
62
|
+
try {
|
|
63
|
+
const file = controlEndpointFile();
|
|
64
|
+
const raw = await readFile(file, "utf8");
|
|
65
|
+
const parsed = JSON.parse(raw);
|
|
66
|
+
if (parsed.pid === process.pid)
|
|
67
|
+
await rm(file, { force: true });
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// Best effort only; stale endpoint files are ignored by the client fallback.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export async function startRynxDaemonServer({ config, warn = defaultWarn, } = {}) {
|
|
74
|
+
applyChannelEnv();
|
|
75
|
+
await loadPlugins(warn);
|
|
76
|
+
const appConfig = config ?? loadConfig();
|
|
77
|
+
logLegacyCleanup(appConfig);
|
|
78
|
+
const server = await startServer({
|
|
79
|
+
config: appConfig,
|
|
80
|
+
loadInstances: () => resolveChannelInstances(warn),
|
|
81
|
+
control: buildControlDeps(),
|
|
82
|
+
sessionLog: new SqliteSessionLogStore(),
|
|
83
|
+
sessionRegistry,
|
|
84
|
+
});
|
|
85
|
+
await writeControlEndpoint(appConfig);
|
|
86
|
+
server.on("close", () => {
|
|
87
|
+
void removeControlEndpointForCurrentProcess();
|
|
88
|
+
});
|
|
89
|
+
return server;
|
|
90
|
+
}
|
package/dist/db.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
/** Absolute path to the SQLite file (override via `RYNX_DB`, else `~/.rynx/rynx.db`). */
|
|
3
|
+
export declare function dbPath(): string;
|
|
4
|
+
/** Open (or reuse) the database at the current path, migrating + importing on first open. */
|
|
5
|
+
export declare function db(): Database.Database;
|
package/dist/db.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `~/.rynx/rynx.db` — the daemon's SQLite store. Concerns kept apart:
|
|
3
|
+
* - `channels` — a channel = a plugin type + its config/credentials (`options`).
|
|
4
|
+
* The transport/connection, configured & authorized on its own.
|
|
5
|
+
* - `instances` — a runnable binding: a channel + an agent (1:1 with a channel,
|
|
6
|
+
* enforced by a UNIQUE channel_id). This is what the daemon mounts.
|
|
7
|
+
* - `plugins` — installed plugins (CLI-managed) the loader imports.
|
|
8
|
+
* - `session_items` — the canonical, append-only conversation log (one row per
|
|
9
|
+
* {@link import("@rynx-ai/core").SessionItem}); see {@link import("./session-log-store.js")}.
|
|
10
|
+
*
|
|
11
|
+
* Connections are cached per resolved path so tests can point `RYNX_DB` /
|
|
12
|
+
* `RYNX_HOME` at a temp dir. Override with `RYNX_DB` (full path) or `RYNX_HOME`.
|
|
13
|
+
*/
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
15
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync } from "node:fs";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import Database from "better-sqlite3";
|
|
18
|
+
import { rynxHome } from "@rynx-ai/core";
|
|
19
|
+
const connections = new Map();
|
|
20
|
+
/** Absolute path to the SQLite file (override via `RYNX_DB`, else `~/.rynx/rynx.db`). */
|
|
21
|
+
export function dbPath() {
|
|
22
|
+
return process.env.RYNX_DB?.trim() || join(rynxHome(), "rynx.db");
|
|
23
|
+
}
|
|
24
|
+
/** Open (or reuse) the database at the current path, migrating + importing on first open. */
|
|
25
|
+
export function db() {
|
|
26
|
+
const path = dbPath();
|
|
27
|
+
const cached = connections.get(path);
|
|
28
|
+
if (cached)
|
|
29
|
+
return cached;
|
|
30
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
31
|
+
const conn = new Database(path);
|
|
32
|
+
conn.pragma("busy_timeout = 5000");
|
|
33
|
+
conn.pragma("foreign_keys = ON");
|
|
34
|
+
migrate(conn);
|
|
35
|
+
// The store may hold channel secrets (e.g. LARK_APP_SECRET) — keep it private.
|
|
36
|
+
try {
|
|
37
|
+
chmodSync(path, 0o600);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
/* best-effort on platforms without POSIX modes */
|
|
41
|
+
}
|
|
42
|
+
connections.set(path, conn);
|
|
43
|
+
return conn;
|
|
44
|
+
}
|
|
45
|
+
function migrate(conn) {
|
|
46
|
+
conn.exec(`
|
|
47
|
+
CREATE TABLE IF NOT EXISTS plugins (
|
|
48
|
+
name TEXT PRIMARY KEY,
|
|
49
|
+
spec TEXT NOT NULL,
|
|
50
|
+
source TEXT NOT NULL,
|
|
51
|
+
version TEXT,
|
|
52
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
53
|
+
installed_at TEXT NOT NULL
|
|
54
|
+
);
|
|
55
|
+
CREATE TABLE IF NOT EXISTS channels (
|
|
56
|
+
id TEXT PRIMARY KEY,
|
|
57
|
+
name TEXT NOT NULL UNIQUE,
|
|
58
|
+
type TEXT NOT NULL,
|
|
59
|
+
options TEXT,
|
|
60
|
+
created_at TEXT NOT NULL,
|
|
61
|
+
updated_at TEXT NOT NULL
|
|
62
|
+
);
|
|
63
|
+
CREATE TABLE IF NOT EXISTS instances (
|
|
64
|
+
id TEXT PRIMARY KEY,
|
|
65
|
+
channel_id TEXT NOT NULL UNIQUE REFERENCES channels(id) ON DELETE CASCADE,
|
|
66
|
+
agent TEXT,
|
|
67
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
68
|
+
created_at TEXT NOT NULL,
|
|
69
|
+
updated_at TEXT NOT NULL
|
|
70
|
+
);
|
|
71
|
+
CREATE TABLE IF NOT EXISTS session_items (
|
|
72
|
+
id TEXT PRIMARY KEY,
|
|
73
|
+
session_id TEXT NOT NULL,
|
|
74
|
+
position INTEGER NOT NULL,
|
|
75
|
+
response_id TEXT NOT NULL,
|
|
76
|
+
type TEXT NOT NULL,
|
|
77
|
+
status TEXT NOT NULL,
|
|
78
|
+
data TEXT NOT NULL,
|
|
79
|
+
created_by TEXT,
|
|
80
|
+
created_at INTEGER NOT NULL,
|
|
81
|
+
UNIQUE(session_id, position)
|
|
82
|
+
);
|
|
83
|
+
CREATE INDEX IF NOT EXISTS idx_session_items_session ON session_items(session_id, position);
|
|
84
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
85
|
+
id TEXT PRIMARY KEY,
|
|
86
|
+
agent TEXT,
|
|
87
|
+
config TEXT,
|
|
88
|
+
model TEXT,
|
|
89
|
+
reasoning_effort TEXT,
|
|
90
|
+
title TEXT,
|
|
91
|
+
source TEXT,
|
|
92
|
+
created_at TEXT NOT NULL,
|
|
93
|
+
updated_at TEXT
|
|
94
|
+
);
|
|
95
|
+
`);
|
|
96
|
+
// `sessions` is the unified machine-session record (console + every channel).
|
|
97
|
+
// Add the identity columns to a pre-existing table (SQLite has no
|
|
98
|
+
// ADD COLUMN IF NOT EXISTS) and backfill the origin of rows written before
|
|
99
|
+
// this change — they were all console sessions.
|
|
100
|
+
addColumnIfMissing(conn, "sessions", "source", "TEXT");
|
|
101
|
+
addColumnIfMissing(conn, "sessions", "updated_at", "TEXT");
|
|
102
|
+
conn.exec("UPDATE sessions SET source = 'console' WHERE source IS NULL");
|
|
103
|
+
importLegacyConfig(conn);
|
|
104
|
+
}
|
|
105
|
+
/** Idempotently add a column to an existing table (no-op if already present). */
|
|
106
|
+
function addColumnIfMissing(conn, table, column, ddl) {
|
|
107
|
+
const cols = conn.prepare(`PRAGMA table_info(${table})`).all();
|
|
108
|
+
if (cols.some((c) => c.name === column))
|
|
109
|
+
return;
|
|
110
|
+
conn.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${ddl}`);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* One-time import of a pre-existing `~/.rynx/config.json` (the old hand-edited
|
|
114
|
+
* format, where each entry mixed channel config + agent binding). Each entry
|
|
115
|
+
* becomes a `channels` row (the config) plus an `instances` row (the binding).
|
|
116
|
+
* The instance keeps the entry's key as its `id` so per-instance on-disk state
|
|
117
|
+
* (keyed by instanceId) stays intact. Runs only when empty; the file is renamed
|
|
118
|
+
* afterwards so it's never read — or hand-edited — again.
|
|
119
|
+
*/
|
|
120
|
+
function importLegacyConfig(conn) {
|
|
121
|
+
const { n } = conn.prepare("SELECT COUNT(*) AS n FROM channels").get();
|
|
122
|
+
if (n > 0)
|
|
123
|
+
return;
|
|
124
|
+
const legacy = process.env.RYNX_CONFIG?.trim() || join(rynxHome(), "config.json");
|
|
125
|
+
if (!existsSync(legacy))
|
|
126
|
+
return;
|
|
127
|
+
let channels;
|
|
128
|
+
try {
|
|
129
|
+
const parsed = JSON.parse(readFileSync(legacy, "utf8"));
|
|
130
|
+
if (parsed.channels && typeof parsed.channels === "object") {
|
|
131
|
+
channels = parsed.channels;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return; // malformed legacy file — leave it alone, start empty
|
|
136
|
+
}
|
|
137
|
+
if (!channels)
|
|
138
|
+
return;
|
|
139
|
+
const now = new Date().toISOString();
|
|
140
|
+
const insertChannel = conn.prepare(`INSERT OR IGNORE INTO channels (id, name, type, options, created_at, updated_at)
|
|
141
|
+
VALUES (@id, @name, @type, @options, @now, @now)`);
|
|
142
|
+
const insertInstance = conn.prepare(`INSERT OR IGNORE INTO instances (id, channel_id, agent, enabled, created_at, updated_at)
|
|
143
|
+
VALUES (@id, @channelId, @agent, @enabled, @now, @now)`);
|
|
144
|
+
const tx = conn.transaction(() => {
|
|
145
|
+
for (const [key, entry] of Object.entries(channels)) {
|
|
146
|
+
const channelId = randomUUID();
|
|
147
|
+
insertChannel.run({
|
|
148
|
+
id: channelId,
|
|
149
|
+
name: key,
|
|
150
|
+
type: entry.type ?? key,
|
|
151
|
+
options: entry.options ? JSON.stringify(entry.options) : null,
|
|
152
|
+
now,
|
|
153
|
+
});
|
|
154
|
+
insertInstance.run({
|
|
155
|
+
id: key, // preserve the old key as the runtime instanceId
|
|
156
|
+
channelId,
|
|
157
|
+
agent: entry.agent ?? null,
|
|
158
|
+
enabled: entry.enabled === false ? 0 : 1,
|
|
159
|
+
now,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
tx();
|
|
164
|
+
try {
|
|
165
|
+
renameSync(legacy, `${legacy}.imported`);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
/* leave the source in place if it can't be renamed */
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The resident daemon entry — this is what pm2 keeps alive.
|
|
4
|
+
*
|
|
5
|
+
* Boot order matters: install the EPIPE guard first (we're a pm2 child).
|
|
6
|
+
* Importing `@rynx-ai/core` hydrates `{RYNX_HOME}/config.json` into the environment,
|
|
7
|
+
* so `loadConfig()` sees runtime settings; then read the channel registry config,
|
|
8
|
+
* resolve enabled channel plugins, and hand the factories to the channel-agnostic
|
|
9
|
+
* `@rynx-ai/server` composition root.
|
|
10
|
+
*/
|
|
11
|
+
import { startRynxDaemonServer } from "./daemon-server.js";
|
|
12
|
+
import { installStdioEpipeGuard } from "./stdio-epipe-guard.js";
|
|
13
|
+
installStdioEpipeGuard();
|
|
14
|
+
async function main() {
|
|
15
|
+
await startRynxDaemonServer();
|
|
16
|
+
}
|
|
17
|
+
main().catch((err) => {
|
|
18
|
+
console.error(`Fatal error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface InstanceConfig {
|
|
2
|
+
id: string;
|
|
3
|
+
channelId: string;
|
|
4
|
+
agent?: string;
|
|
5
|
+
enabled: boolean;
|
|
6
|
+
}
|
|
7
|
+
/** An instance joined with its channel — the shape the control plane lists. */
|
|
8
|
+
export interface InstanceView {
|
|
9
|
+
id: string;
|
|
10
|
+
channelId: string;
|
|
11
|
+
channelName: string;
|
|
12
|
+
type: string;
|
|
13
|
+
agent?: string;
|
|
14
|
+
enabled: boolean;
|
|
15
|
+
options?: Record<string, unknown>;
|
|
16
|
+
}
|
|
17
|
+
/** A mountable binding: instance id + its channel's type/options + bound agent. */
|
|
18
|
+
export interface InstanceDescriptor {
|
|
19
|
+
instanceId: string;
|
|
20
|
+
type: string;
|
|
21
|
+
options?: Record<string, unknown>;
|
|
22
|
+
agent?: string;
|
|
23
|
+
}
|
|
24
|
+
export declare function listInstances(): InstanceView[];
|
|
25
|
+
export declare function getInstance(id: string): InstanceView | undefined;
|
|
26
|
+
/** Mountable descriptors for every enabled instance (joined with its channel). */
|
|
27
|
+
export declare function listEnabledDescriptors(): InstanceDescriptor[];
|
|
28
|
+
/** Bind a channel to an agent. Throws if the channel already has an instance. */
|
|
29
|
+
export declare function createInstance(input: {
|
|
30
|
+
channelId: string;
|
|
31
|
+
agent?: string;
|
|
32
|
+
enabled?: boolean;
|
|
33
|
+
}): InstanceConfig;
|
|
34
|
+
export declare function setInstanceAgent(id: string, agent: string | undefined): void;
|
|
35
|
+
export declare function setInstanceEnabled(id: string, enabled: boolean): void;
|
|
36
|
+
export declare function removeInstance(id: string): void;
|
|
37
|
+
/** The instance bound to a channel, if any (1:1). */
|
|
38
|
+
export declare function getInstanceByChannel(channelId: string): InstanceView | undefined;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `instances` table CRUD — a runnable binding of a channel to an agent (1:1 with
|
|
3
|
+
* a channel, enforced by a UNIQUE channel_id). The daemon mounts enabled
|
|
4
|
+
* instances; each resolves to its channel's type + options plus the bound agent.
|
|
5
|
+
*/
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { db } from "./db.js";
|
|
8
|
+
function parseOptions(raw) {
|
|
9
|
+
if (!raw)
|
|
10
|
+
return undefined;
|
|
11
|
+
try {
|
|
12
|
+
const parsed = JSON.parse(raw);
|
|
13
|
+
return parsed && typeof parsed === "object" ? parsed : undefined;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const JOIN_SELECT = `
|
|
20
|
+
SELECT i.id, i.channel_id, i.agent, i.enabled,
|
|
21
|
+
c.name AS channel_name, c.type AS type, c.options AS options
|
|
22
|
+
FROM instances i JOIN channels c ON c.id = i.channel_id`;
|
|
23
|
+
function toView(row) {
|
|
24
|
+
return {
|
|
25
|
+
id: row.id,
|
|
26
|
+
channelId: row.channel_id,
|
|
27
|
+
channelName: row.channel_name,
|
|
28
|
+
type: row.type,
|
|
29
|
+
agent: row.agent ?? undefined,
|
|
30
|
+
enabled: row.enabled !== 0,
|
|
31
|
+
options: parseOptions(row.options),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function listInstances() {
|
|
35
|
+
const rows = db().prepare(`${JOIN_SELECT} ORDER BY c.name`).all();
|
|
36
|
+
return rows.map(toView);
|
|
37
|
+
}
|
|
38
|
+
export function getInstance(id) {
|
|
39
|
+
const row = db().prepare(`${JOIN_SELECT} WHERE i.id = ?`).get(id);
|
|
40
|
+
return row ? toView(row) : undefined;
|
|
41
|
+
}
|
|
42
|
+
/** Mountable descriptors for every enabled instance (joined with its channel). */
|
|
43
|
+
export function listEnabledDescriptors() {
|
|
44
|
+
const rows = db().prepare(`${JOIN_SELECT} WHERE i.enabled = 1`).all();
|
|
45
|
+
return rows.map((row) => ({
|
|
46
|
+
instanceId: row.id,
|
|
47
|
+
type: row.type,
|
|
48
|
+
options: parseOptions(row.options),
|
|
49
|
+
agent: row.agent ?? undefined,
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
/** Bind a channel to an agent. Throws if the channel already has an instance. */
|
|
53
|
+
export function createInstance(input) {
|
|
54
|
+
const id = randomUUID();
|
|
55
|
+
const now = new Date().toISOString();
|
|
56
|
+
db()
|
|
57
|
+
.prepare(`INSERT INTO instances (id, channel_id, agent, enabled, created_at, updated_at)
|
|
58
|
+
VALUES (@id, @channelId, @agent, @enabled, @now, @now)`)
|
|
59
|
+
.run({
|
|
60
|
+
id,
|
|
61
|
+
channelId: input.channelId,
|
|
62
|
+
agent: input.agent ?? null,
|
|
63
|
+
enabled: input.enabled === false ? 0 : 1,
|
|
64
|
+
now,
|
|
65
|
+
});
|
|
66
|
+
return { id, channelId: input.channelId, agent: input.agent, enabled: input.enabled !== false };
|
|
67
|
+
}
|
|
68
|
+
export function setInstanceAgent(id, agent) {
|
|
69
|
+
db()
|
|
70
|
+
.prepare("UPDATE instances SET agent = ?, updated_at = ? WHERE id = ?")
|
|
71
|
+
.run(agent ?? null, new Date().toISOString(), id);
|
|
72
|
+
}
|
|
73
|
+
export function setInstanceEnabled(id, enabled) {
|
|
74
|
+
db()
|
|
75
|
+
.prepare("UPDATE instances SET enabled = ?, updated_at = ? WHERE id = ?")
|
|
76
|
+
.run(enabled ? 1 : 0, new Date().toISOString(), id);
|
|
77
|
+
}
|
|
78
|
+
export function removeInstance(id) {
|
|
79
|
+
db().prepare("DELETE FROM instances WHERE id = ?").run(id);
|
|
80
|
+
}
|
|
81
|
+
/** The instance bound to a channel, if any (1:1). */
|
|
82
|
+
export function getInstanceByChannel(channelId) {
|
|
83
|
+
const row = db().prepare(`${JOIN_SELECT} WHERE i.channel_id = ?`).get(channelId);
|
|
84
|
+
return row ? toView(row) : undefined;
|
|
85
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface CleanupOptions {
|
|
2
|
+
/** CodexSessionStore JSON path; defaults to `<cwd>/.codex-proxy/sessions.json`. */
|
|
3
|
+
codexStorePath?: string;
|
|
4
|
+
/** Dir holding the channel routing maps (`lark-sessions*.json`); defaults to
|
|
5
|
+
* `<cwd>/.codex-proxy`. */
|
|
6
|
+
channelStoreDir?: string;
|
|
7
|
+
/** Report what would be removed without deleting anything. */
|
|
8
|
+
dryRun?: boolean;
|
|
9
|
+
log?: (entry: Record<string, unknown>) => void;
|
|
10
|
+
}
|
|
11
|
+
export interface CleanupResult {
|
|
12
|
+
dryRun: boolean;
|
|
13
|
+
deletedLogRows: number;
|
|
14
|
+
deletedSessions: number;
|
|
15
|
+
/** Legacy entries pruned from the JSON stores. */
|
|
16
|
+
prunedStoreEntries: number;
|
|
17
|
+
}
|
|
18
|
+
export declare function cleanupLegacySessions(opts?: CleanupOptions): CleanupResult;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-healing cleanup of legacy, origin-encoded sessions.
|
|
3
|
+
*
|
|
4
|
+
* Sessions now carry an opaque `sess_<uuid>` id. Any session keyed by a legacy id
|
|
5
|
+
* (`session_<uuid>` console, `lark:…:session:<uuid>` / `lark:…` channel) is simply
|
|
6
|
+
* discarded — not migrated — so the system stays clean and every channel re-mints
|
|
7
|
+
* a fresh `sess_` session on its next message.
|
|
8
|
+
*
|
|
9
|
+
* Deletes:
|
|
10
|
+
* - canonical-log rows (`session_items`) and identity rows (`sessions`) whose
|
|
11
|
+
* id is not an opaque `sess_` id,
|
|
12
|
+
* - the runtime thread→rollout bindings `.codex-proxy/sessions.json` and the
|
|
13
|
+
* channel routing maps `.codex-proxy/lark-sessions*.json` entries that point
|
|
14
|
+
* at a legacy id (the externalKey KEYS go too, so the next message bootstraps
|
|
15
|
+
* a fresh session).
|
|
16
|
+
*
|
|
17
|
+
* Runs on every daemon boot (not marker-gated): on a healthy system it deletes
|
|
18
|
+
* nothing and writes nothing, so it is cheap; if a legacy session ever appears
|
|
19
|
+
* (e.g. an old channel plugin produced one), the next boot removes it. The
|
|
20
|
+
* `sess_` filter means it can never touch a current session.
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import { isMachineSessionId } from "@rynx-ai/core";
|
|
25
|
+
import { db } from "../db.js";
|
|
26
|
+
/** Mirrors the channel stores' own resolution (`<cwd>/.codex-proxy`). Kept local
|
|
27
|
+
* so the daemon needn't depend on `@rynx-ai/runtime` / the lark plugin. */
|
|
28
|
+
function defaultProxyDir() {
|
|
29
|
+
return path.join(process.cwd(), ".codex-proxy");
|
|
30
|
+
}
|
|
31
|
+
// Anything whose id isn't an opaque `sess_` id is legacy. GLOB treats `_` as a
|
|
32
|
+
// literal (unlike LIKE), so `sess_*` matches exactly the new id shape.
|
|
33
|
+
const LEGACY_LOG = "session_id NOT GLOB 'sess_*'";
|
|
34
|
+
const LEGACY_META = "id NOT GLOB 'sess_*'";
|
|
35
|
+
export function cleanupLegacySessions(opts = {}) {
|
|
36
|
+
const log = opts.log ?? (() => { });
|
|
37
|
+
const dryRun = Boolean(opts.dryRun);
|
|
38
|
+
const conn = db();
|
|
39
|
+
const codexStorePath = opts.codexStorePath ?? path.join(defaultProxyDir(), "sessions.json");
|
|
40
|
+
const channelStoreDir = opts.channelStoreDir ?? defaultProxyDir();
|
|
41
|
+
const larkFiles = listLarkStoreFiles(channelStoreDir);
|
|
42
|
+
if (dryRun) {
|
|
43
|
+
const deletedLogRows = countRows(conn, `SELECT COUNT(*) AS n FROM session_items WHERE ${LEGACY_LOG}`);
|
|
44
|
+
const deletedSessions = countRows(conn, `SELECT COUNT(*) AS n FROM sessions WHERE ${LEGACY_META}`);
|
|
45
|
+
const prunedStoreEntries = countLegacyStoreEntries(readJsonStore(codexStorePath), (key) => key) +
|
|
46
|
+
larkFiles.reduce((sum, file) => sum + countLegacyStoreEntries(readJsonStore(file), (_k, rec) => rec.sessionId), 0);
|
|
47
|
+
log({ event: "cleanup.dry_run", deletedLogRows, deletedSessions, prunedStoreEntries });
|
|
48
|
+
return { dryRun: true, deletedLogRows, deletedSessions, prunedStoreEntries };
|
|
49
|
+
}
|
|
50
|
+
let deletedLogRows = 0;
|
|
51
|
+
let deletedSessions = 0;
|
|
52
|
+
let prunedStoreEntries = 0;
|
|
53
|
+
conn.transaction(() => {
|
|
54
|
+
deletedLogRows = conn.prepare(`DELETE FROM session_items WHERE ${LEGACY_LOG}`).run().changes;
|
|
55
|
+
deletedSessions = conn.prepare(`DELETE FROM sessions WHERE ${LEGACY_META}`).run().changes;
|
|
56
|
+
})();
|
|
57
|
+
// Prune the JSON stores: a codex entry keyed by a legacy id, or a routing entry
|
|
58
|
+
// whose value points at one. Removing the routing entry makes the next message
|
|
59
|
+
// bootstrap a fresh `sess_` session for that conversation.
|
|
60
|
+
prunedStoreEntries += pruneStore(codexStorePath, (key) => key);
|
|
61
|
+
for (const file of larkFiles)
|
|
62
|
+
prunedStoreEntries += pruneStore(file, (_k, rec) => rec.sessionId);
|
|
63
|
+
if (deletedLogRows || deletedSessions || prunedStoreEntries) {
|
|
64
|
+
log({ event: "cleanup.done", deletedLogRows, deletedSessions, prunedStoreEntries });
|
|
65
|
+
}
|
|
66
|
+
return { dryRun: false, deletedLogRows, deletedSessions, prunedStoreEntries };
|
|
67
|
+
}
|
|
68
|
+
function countRows(conn, sql) {
|
|
69
|
+
return conn.prepare(sql).get().n;
|
|
70
|
+
}
|
|
71
|
+
function countLegacyStoreEntries(data, idOf) {
|
|
72
|
+
let n = 0;
|
|
73
|
+
for (const [key, record] of Object.entries(data.sessions ?? {})) {
|
|
74
|
+
const id = idOf(key, record);
|
|
75
|
+
if (typeof id === "string" && !isMachineSessionId(id))
|
|
76
|
+
n += 1;
|
|
77
|
+
}
|
|
78
|
+
return n;
|
|
79
|
+
}
|
|
80
|
+
/** Drop every legacy entry from a JSON store; returns how many were removed. */
|
|
81
|
+
function pruneStore(file, idOf) {
|
|
82
|
+
const data = readJsonStore(file);
|
|
83
|
+
if (!data.sessions)
|
|
84
|
+
return 0;
|
|
85
|
+
let pruned = 0;
|
|
86
|
+
const kept = {};
|
|
87
|
+
for (const [key, record] of Object.entries(data.sessions)) {
|
|
88
|
+
const id = idOf(key, record);
|
|
89
|
+
if (typeof id === "string" && !isMachineSessionId(id)) {
|
|
90
|
+
pruned += 1;
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
kept[key] = record;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (pruned > 0) {
|
|
97
|
+
data.sessions = kept;
|
|
98
|
+
writeJsonStore(file, data);
|
|
99
|
+
}
|
|
100
|
+
return pruned;
|
|
101
|
+
}
|
|
102
|
+
function listLarkStoreFiles(dir) {
|
|
103
|
+
if (!existsSync(dir))
|
|
104
|
+
return [];
|
|
105
|
+
return readdirSync(dir)
|
|
106
|
+
.filter((name) => /^lark-sessions.*\.json$/.test(name))
|
|
107
|
+
.map((name) => path.join(dir, name));
|
|
108
|
+
}
|
|
109
|
+
function readJsonStore(file) {
|
|
110
|
+
try {
|
|
111
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
112
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return {};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function writeJsonStore(file, data) {
|
|
119
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
120
|
+
const tmp = path.join(path.dirname(file), `.${path.basename(file)}.cleanup.tmp`);
|
|
121
|
+
writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, "utf8");
|
|
122
|
+
renameSync(tmp, file);
|
|
123
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type PluginRecord, type PluginSource } from "./plugin-store.js";
|
|
2
|
+
export interface InstallResult {
|
|
3
|
+
name: string;
|
|
4
|
+
source: PluginSource;
|
|
5
|
+
version?: string;
|
|
6
|
+
/** True when an existing same-named plugin was kept (user declined to overwrite). */
|
|
7
|
+
skipped?: boolean;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Install (or re-install) a plugin from an npm spec or a local directory.
|
|
11
|
+
* - **local**: copy the package (minus `node_modules`) into `~/.rynx/plugins/<name>`
|
|
12
|
+
* and load it there. The plugin must be self-contained — it bundles its deps at
|
|
13
|
+
* build time (incl. the unpublished workspace `@rynx-ai/core`) and declares a
|
|
14
|
+
* `rynx.plugin` entry; the daemon installs nothing.
|
|
15
|
+
* - **npm**: `npm install` into `~/.rynx/plugins` (deps resolved by npm).
|
|
16
|
+
*
|
|
17
|
+
* `onConflict` is consulted when a plugin of the same `name` already exists; if it
|
|
18
|
+
* returns false the install is aborted with `{ skipped: true }` (nothing changes).
|
|
19
|
+
* `onProgress` streams human-readable lines.
|
|
20
|
+
*/
|
|
21
|
+
export declare function installPlugin(spec: string, onProgress?: (line: string) => void, opts?: {
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
onConflict?: (existing: PluginRecord) => boolean | Promise<boolean>;
|
|
24
|
+
}): Promise<InstallResult>;
|
|
25
|
+
/** Uninstall a plugin: drop its record, npm-uninstall if applicable, refresh catalog. */
|
|
26
|
+
export declare function uninstallPlugin(name: string, onProgress?: (line: string) => void): Promise<boolean>;
|
|
27
|
+
export type { PluginRecord };
|