@rynx-ai/daemon 0.1.10 → 0.1.11-beta.2

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.
Files changed (117) hide show
  1. package/bundled-plugins/.rynx-plugin/marketplace.json +10 -0
  2. package/bundled-plugins/plugins/lark/.rynx-plugin/plugin.json +37 -0
  3. package/bundled-plugins/plugins/lark/dist/cli-mirror/mirror-index.d.ts +48 -0
  4. package/bundled-plugins/plugins/lark/dist/cli-mirror/mirror-index.js +54 -0
  5. package/bundled-plugins/plugins/lark/dist/cli-mirror/rollout-mirror.d.ts +85 -0
  6. package/bundled-plugins/plugins/lark/dist/cli-mirror/rollout-mirror.js +333 -0
  7. package/bundled-plugins/plugins/lark/dist/cli-mirror/rollout-watcher.d.ts +109 -0
  8. package/bundled-plugins/plugins/lark/dist/cli-mirror/rollout-watcher.js +347 -0
  9. package/bundled-plugins/plugins/lark/dist/host-adapters.d.ts +95 -0
  10. package/bundled-plugins/plugins/lark/dist/host-adapters.js +504 -0
  11. package/bundled-plugins/plugins/lark/dist/index.d.ts +7 -0
  12. package/bundled-plugins/plugins/lark/dist/index.js +7 -0
  13. package/bundled-plugins/plugins/lark/dist/lark-card-stream.d.ts +363 -0
  14. package/bundled-plugins/plugins/lark/dist/lark-card-stream.js +1335 -0
  15. package/bundled-plugins/plugins/lark/dist/lark-config-store.d.ts +30 -0
  16. package/bundled-plugins/plugins/lark/dist/lark-config-store.js +135 -0
  17. package/bundled-plugins/plugins/lark/dist/lark-content.d.ts +30 -0
  18. package/bundled-plugins/plugins/lark/dist/lark-content.js +138 -0
  19. package/bundled-plugins/plugins/lark/dist/lark-conversation-directory.d.ts +65 -0
  20. package/bundled-plugins/plugins/lark/dist/lark-conversation-directory.js +106 -0
  21. package/bundled-plugins/plugins/lark/dist/lark-diff-card.d.ts +25 -0
  22. package/bundled-plugins/plugins/lark/dist/lark-diff-card.js +58 -0
  23. package/bundled-plugins/plugins/lark/dist/lark-fork-transcript.d.ts +16 -0
  24. package/bundled-plugins/plugins/lark/dist/lark-fork-transcript.js +141 -0
  25. package/bundled-plugins/plugins/lark/dist/lark-resume-card.d.ts +31 -0
  26. package/bundled-plugins/plugins/lark/dist/lark-resume-card.js +105 -0
  27. package/bundled-plugins/plugins/lark/dist/lark-runtime-state-store.d.ts +18 -0
  28. package/bundled-plugins/plugins/lark/dist/lark-runtime-state-store.js +95 -0
  29. package/bundled-plugins/plugins/lark/dist/lark-sdk/client.d.ts +8 -0
  30. package/bundled-plugins/plugins/lark/dist/lark-sdk/client.js +32 -0
  31. package/bundled-plugins/plugins/lark/dist/lark-sdk/index.d.ts +1 -0
  32. package/bundled-plugins/plugins/lark/dist/lark-sdk/index.js +1 -0
  33. package/bundled-plugins/plugins/lark/dist/lark-sender.d.ts +114 -0
  34. package/bundled-plugins/plugins/lark/dist/lark-sender.js +323 -0
  35. package/bundled-plugins/plugins/lark/dist/lark-session-store.d.ts +13 -0
  36. package/bundled-plugins/plugins/lark/dist/lark-session-store.js +14 -0
  37. package/bundled-plugins/plugins/lark/dist/lark-settings-card.d.ts +55 -0
  38. package/bundled-plugins/plugins/lark/dist/lark-settings-card.js +158 -0
  39. package/bundled-plugins/plugins/lark/dist/lark-setup.d.ts +39 -0
  40. package/bundled-plugins/plugins/lark/dist/lark-setup.js +103 -0
  41. package/bundled-plugins/plugins/lark/dist/lark.d.ts +507 -0
  42. package/bundled-plugins/plugins/lark/dist/lark.js +1977 -0
  43. package/bundled-plugins/plugins/lark/dist/runtime.d.ts +13 -0
  44. package/bundled-plugins/plugins/lark/dist/runtime.js +131316 -0
  45. package/bundled-plugins/plugins/lark/dist/settings.d.ts +7 -0
  46. package/bundled-plugins/plugins/lark/dist/settings.js +126958 -0
  47. package/bundled-plugins/plugins/lark/package.json +57 -0
  48. package/bundled-plugins/plugins/lark/ui/assets/index-B2dhMlMU.css +1 -0
  49. package/bundled-plugins/plugins/lark/ui/assets/index-D9N-AkLX.js +201 -0
  50. package/bundled-plugins/plugins/lark/ui/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  51. package/bundled-plugins/plugins/lark/ui/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  52. package/bundled-plugins/plugins/lark/ui/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  53. package/bundled-plugins/plugins/lark/ui/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  54. package/bundled-plugins/plugins/lark/ui/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  55. package/bundled-plugins/plugins/lark/ui/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  56. package/bundled-plugins/plugins/lark/ui/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  57. package/bundled-plugins/plugins/lark/ui/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  58. package/bundled-plugins/plugins/lark/ui/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  59. package/bundled-plugins/plugins/lark/ui/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  60. package/bundled-plugins/plugins/lark/ui/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  61. package/bundled-plugins/plugins/lark/ui/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  62. package/bundled-plugins/plugins/lark/ui/index.html +13 -0
  63. package/dist/bundled-plugins.d.ts +15 -0
  64. package/dist/bundled-plugins.js +134 -0
  65. package/dist/cli.js +26 -208
  66. package/dist/control-deps.js +2 -79
  67. package/dist/daemon-server.d.ts +4 -1
  68. package/dist/daemon-server.js +167 -57
  69. package/dist/db.js +145 -124
  70. package/dist/index-daemon.js +25 -4
  71. package/dist/migrations/cleanup-legacy-sessions.d.ts +1 -1
  72. package/dist/migrations/cleanup-legacy-sessions.js +33 -15
  73. package/dist/official-plugins.d.ts +2 -0
  74. package/dist/official-plugins.js +2 -0
  75. package/dist/plugin-cli.d.ts +1 -3
  76. package/dist/plugin-cli.js +56 -45
  77. package/dist/plugin-host-leases.d.ts +4 -0
  78. package/dist/plugin-host-leases.js +7 -0
  79. package/dist/plugin-host-rpc.d.ts +42 -4
  80. package/dist/plugin-host-rpc.js +144 -34
  81. package/dist/plugin-installer.d.ts +24 -55
  82. package/dist/plugin-installer.js +202 -415
  83. package/dist/plugin-management-service.d.ts +30 -23
  84. package/dist/plugin-management-service.js +290 -105
  85. package/dist/plugin-marketplace.d.ts +35 -0
  86. package/dist/plugin-marketplace.js +299 -0
  87. package/dist/plugin-package.d.ts +22 -24
  88. package/dist/plugin-package.js +520 -571
  89. package/dist/plugin-skill-context.d.ts +4 -0
  90. package/dist/plugin-skill-context.js +35 -0
  91. package/dist/plugin-store.d.ts +36 -37
  92. package/dist/plugin-store.js +140 -92
  93. package/dist/plugin-supervisor.d.ts +45 -125
  94. package/dist/plugin-supervisor.js +516 -1053
  95. package/dist/pm2.js +15 -10
  96. package/dist/runtime-project-store.d.ts +9 -0
  97. package/dist/runtime-project-store.js +83 -0
  98. package/dist/session-fallback-workspace.d.ts +22 -0
  99. package/dist/session-fallback-workspace.js +120 -0
  100. package/dist/session-fork-store.d.ts +41 -0
  101. package/dist/session-fork-store.js +288 -0
  102. package/dist/session-launch-operations.d.ts +14 -2
  103. package/dist/session-launch-operations.js +34 -2
  104. package/dist/session-meta-store.d.ts +7 -9
  105. package/dist/session-meta-store.js +28 -49
  106. package/dist/setup.js +26 -14
  107. package/package.json +14 -11
  108. package/dist/channel-store.d.ts +0 -22
  109. package/dist/channel-store.js +0 -65
  110. package/dist/instance-store.d.ts +0 -38
  111. package/dist/instance-store.js +0 -85
  112. package/dist/plugin-install-restart.d.ts +0 -13
  113. package/dist/plugin-install-restart.js +0 -24
  114. package/dist/plugin-runtime-control.d.ts +0 -2
  115. package/dist/plugin-runtime-control.js +0 -37
  116. package/dist/registry.d.ts +0 -10
  117. package/dist/registry.js +0 -52
package/dist/pm2.js CHANGED
@@ -10,10 +10,9 @@
10
10
  import { spawn, spawnSync } from "node:child_process";
11
11
  import { mkdirSync, realpathSync } from "node:fs";
12
12
  import { createRequire } from "node:module";
13
- import { homedir } from "node:os";
14
13
  import { dirname, join, resolve } from "node:path";
15
14
  import { fileURLToPath } from "node:url";
16
- import { loadConfig } from "@rynx-ai/core";
15
+ import { loadConfig, rynxHome } from "@rynx-ai/core";
17
16
  import { daemonEntryRestartRequired } from "./daemon-build-status.js";
18
17
  const require = createRequire(import.meta.url);
19
18
  /** pm2 app name — single resident daemon per machine. */
@@ -55,16 +54,22 @@ function daemonEntryPath() {
55
54
  return join(dirname(fileURLToPath(import.meta.url)), "index-daemon.js");
56
55
  }
57
56
  function logsDir() {
58
- const home = process.env.RYNX_HOME?.trim() || join(homedir(), ".rynx");
59
- const dir = join(home, "logs");
57
+ const dir = join(rynxHome(), "logs");
60
58
  mkdirSync(dir, { recursive: true });
61
59
  return dir;
62
60
  }
63
61
  function nodeRuntimeEnv() {
64
- if (!process.versions.electron) {
65
- return process.env;
66
- }
67
- return { ...process.env, ELECTRON_RUN_AS_NODE: "1" };
62
+ const home = rynxHome();
63
+ const pm2Home = join(home, "pm2");
64
+ mkdirSync(pm2Home, { recursive: true, mode: 0o700 });
65
+ return {
66
+ ...process.env,
67
+ RYNX_HOME: home,
68
+ PM2_HOME: pm2Home,
69
+ ...(process.versions.electron
70
+ ? { ELECTRON_RUN_AS_NODE: "1" }
71
+ : {}),
72
+ };
68
73
  }
69
74
  /** Run a pm2 command inheriting stdio; returns its exit code. */
70
75
  function runPm2(args, quiet = false) {
@@ -145,9 +150,9 @@ export function startDaemon({ force, quiet = false }) {
145
150
  daemonEntryPath(),
146
151
  "--name",
147
152
  APP_NAME,
148
- // Resolve `.env` relative to where the user invoked `rynx start`.
153
+ // App and CLI share one daemon-owned state/workspace root.
149
154
  "--cwd",
150
- process.cwd(),
155
+ rynxHome(),
151
156
  "--max-restarts",
152
157
  "10",
153
158
  "--restart-delay",
@@ -0,0 +1,9 @@
1
+ import { type RuntimeProject, type RuntimeProjectInput, type RuntimeProjectStore } from "@rynx-ai/core";
2
+ /** SQLite-backed Project catalog owned by the current target Runtime. */
3
+ export declare class SqliteRuntimeProjectStore implements RuntimeProjectStore {
4
+ list(): Promise<RuntimeProject[]>;
5
+ get(id: string): Promise<RuntimeProject | undefined>;
6
+ create(input: RuntimeProjectInput): Promise<RuntimeProject>;
7
+ update(id: string, input: RuntimeProjectInput): Promise<RuntimeProject | undefined>;
8
+ remove(id: string): Promise<boolean>;
9
+ }
@@ -0,0 +1,83 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { canonicalizeRuntimeProjectInput, runtimeProjectSchema, } from "@rynx-ai/core";
3
+ import { db } from "./db.js";
4
+ const COLUMNS = "id, name, directories_json, primary_dir, created_at, updated_at";
5
+ /** SQLite-backed Project catalog owned by the current target Runtime. */
6
+ export class SqliteRuntimeProjectStore {
7
+ async list() {
8
+ return db()
9
+ .prepare(`SELECT ${COLUMNS} FROM runtime_projects ORDER BY updated_at DESC, id`)
10
+ .all()
11
+ .map((row) => rowToProject(row));
12
+ }
13
+ async get(id) {
14
+ const row = db()
15
+ .prepare(`SELECT ${COLUMNS} FROM runtime_projects WHERE id = ?`)
16
+ .get(id);
17
+ return row ? rowToProject(row) : undefined;
18
+ }
19
+ async create(input) {
20
+ const resolved = await canonicalizeRuntimeProjectInput(input);
21
+ const timestamp = new Date().toISOString();
22
+ const project = {
23
+ id: randomUUID(),
24
+ ...resolved,
25
+ createdAt: timestamp,
26
+ updatedAt: timestamp,
27
+ };
28
+ const parsed = runtimeProjectSchema.parse(project);
29
+ db()
30
+ .prepare(`INSERT INTO runtime_projects
31
+ (id, name, directories_json, primary_dir, created_at, updated_at)
32
+ VALUES (@id, @name, @directories, @primary, @createdAt, @updatedAt)`)
33
+ .run({
34
+ id: parsed.id,
35
+ name: parsed.name,
36
+ directories: JSON.stringify(parsed.directories),
37
+ primary: parsed.primary,
38
+ createdAt: parsed.createdAt,
39
+ updatedAt: parsed.updatedAt,
40
+ });
41
+ return parsed;
42
+ }
43
+ async update(id, input) {
44
+ const current = await this.get(id);
45
+ if (!current)
46
+ return undefined;
47
+ const resolved = await canonicalizeRuntimeProjectInput(input);
48
+ const project = runtimeProjectSchema.parse({
49
+ id: current.id,
50
+ ...resolved,
51
+ createdAt: current.createdAt,
52
+ updatedAt: new Date().toISOString(),
53
+ });
54
+ db()
55
+ .prepare(`UPDATE runtime_projects
56
+ SET name = @name,
57
+ directories_json = @directories,
58
+ primary_dir = @primary,
59
+ updated_at = @updatedAt
60
+ WHERE id = @id`)
61
+ .run({
62
+ id: project.id,
63
+ name: project.name,
64
+ directories: JSON.stringify(project.directories),
65
+ primary: project.primary,
66
+ updatedAt: project.updatedAt,
67
+ });
68
+ return project;
69
+ }
70
+ async remove(id) {
71
+ return db().prepare("DELETE FROM runtime_projects WHERE id = ?").run(id).changes > 0;
72
+ }
73
+ }
74
+ function rowToProject(row) {
75
+ return runtimeProjectSchema.parse({
76
+ id: row.id,
77
+ name: row.name,
78
+ directories: JSON.parse(row.directories_json),
79
+ primary: row.primary_dir,
80
+ createdAt: row.created_at,
81
+ updatedAt: row.updated_at,
82
+ });
83
+ }
@@ -0,0 +1,22 @@
1
+ import { type SessionWorkspaceSnapshot } from "@rynx-ai/core";
2
+ export interface SessionFallbackRootOptions {
3
+ platform?: NodeJS.Platform;
4
+ home?: string;
5
+ env?: NodeJS.ProcessEnv;
6
+ readTextFile?: (path: string) => Promise<string>;
7
+ }
8
+ export interface SessionFallbackWorkspaceOptions extends SessionFallbackRootOptions {
9
+ now?: () => Date;
10
+ randomId?: () => string;
11
+ }
12
+ /**
13
+ * Resolve the user-visible root for Sessions created without a Project.
14
+ *
15
+ * Linux desktop environments can localize or relocate Documents through the
16
+ * XDG user-dirs file. Headless installations commonly omit that file, so they
17
+ * fall back to a visible `$HOME/Rynx` instead of creating an arbitrary
18
+ * `$HOME/Documents` tree. Other platforms retain the existing Documents root.
19
+ */
20
+ export declare function resolveSessionFallbackRoot(options?: SessionFallbackRootOptions): Promise<string>;
21
+ export declare function createSessionFallbackWorkspace(options?: SessionFallbackWorkspaceOptions): Promise<SessionWorkspaceSnapshot>;
22
+ export declare function discardSessionFallbackWorkspace(workspace: SessionWorkspaceSnapshot, options?: SessionFallbackRootOptions): Promise<void>;
@@ -0,0 +1,120 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, realpath, rm } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { isAbsolute, join, normalize, relative } from "node:path";
5
+ import { sessionWorkspaceSnapshotSchema, } from "@rynx-ai/core";
6
+ /**
7
+ * Resolve the user-visible root for Sessions created without a Project.
8
+ *
9
+ * Linux desktop environments can localize or relocate Documents through the
10
+ * XDG user-dirs file. Headless installations commonly omit that file, so they
11
+ * fall back to a visible `$HOME/Rynx` instead of creating an arbitrary
12
+ * `$HOME/Documents` tree. Other platforms retain the existing Documents root.
13
+ */
14
+ export async function resolveSessionFallbackRoot(options = {}) {
15
+ const platform = options.platform ?? process.platform;
16
+ const home = normalize(options.home ?? homedir());
17
+ if (!isAbsolute(home))
18
+ throw new Error("Session fallback home must be absolute");
19
+ if (platform !== "linux")
20
+ return join(home, "Documents", "Rynx");
21
+ const env = options.env ?? process.env;
22
+ const configHome = env.XDG_CONFIG_HOME && isAbsolute(env.XDG_CONFIG_HOME)
23
+ ? normalize(env.XDG_CONFIG_HOME)
24
+ : join(home, ".config");
25
+ const readTextFile = options.readTextFile ?? ((path) => readFile(path, "utf8"));
26
+ try {
27
+ const contents = await readTextFile(join(configHome, "user-dirs.dirs"));
28
+ const documents = parseXdgDocumentsDirectory(contents, home);
29
+ if (documents)
30
+ return join(documents, "Rynx");
31
+ }
32
+ catch {
33
+ // Missing or unreadable XDG configuration is normal on headless Linux.
34
+ }
35
+ return join(home, "Rynx");
36
+ }
37
+ export async function createSessionFallbackWorkspace(options = {}) {
38
+ const now = (options.now ?? (() => new Date()))();
39
+ const date = [
40
+ now.getFullYear(),
41
+ String(now.getMonth() + 1).padStart(2, "0"),
42
+ String(now.getDate()).padStart(2, "0"),
43
+ ].join("-");
44
+ const fallbackRoot = await resolveSessionFallbackRoot(options);
45
+ await mkdir(fallbackRoot, { recursive: true });
46
+ const canonicalFallbackRoot = await realpath(fallbackRoot);
47
+ const dateDirectory = join(canonicalFallbackRoot, date);
48
+ await mkdir(dateDirectory, { recursive: true });
49
+ const canonicalDateDirectory = await realpath(dateDirectory);
50
+ if (relative(canonicalFallbackRoot, canonicalDateDirectory) !== date) {
51
+ throw new Error("Session fallback date directory resolves outside the managed root");
52
+ }
53
+ const randomId = options.randomId ?? randomUUID;
54
+ for (let attempt = 0; attempt < 8; attempt += 1) {
55
+ const directory = join(canonicalDateDirectory, randomId().slice(0, 8));
56
+ try {
57
+ await mkdir(directory);
58
+ const canonical = await realpath(directory);
59
+ return sessionWorkspaceSnapshotSchema.parse({
60
+ cwd: canonical,
61
+ allowedDirs: [canonical],
62
+ });
63
+ }
64
+ catch (error) {
65
+ if (error.code !== "EEXIST")
66
+ throw error;
67
+ }
68
+ }
69
+ throw new Error("could not allocate a unique Session workspace directory");
70
+ }
71
+ export async function discardSessionFallbackWorkspace(workspace, options = {}) {
72
+ if (workspace.allowedDirs.length !== 1
73
+ || workspace.allowedDirs[0] !== workspace.cwd) {
74
+ return;
75
+ }
76
+ let canonicalRoot;
77
+ try {
78
+ canonicalRoot = await realpath(await resolveSessionFallbackRoot(options));
79
+ }
80
+ catch {
81
+ return;
82
+ }
83
+ const relativePath = relative(canonicalRoot, workspace.cwd);
84
+ const segments = relativePath.split(/[\\/]/);
85
+ if (isAbsolute(relativePath)
86
+ || relativePath.startsWith("..")
87
+ || segments.length !== 2
88
+ || !/^\d{4}-\d{2}-\d{2}$/.test(segments[0] ?? "")
89
+ || !/^[0-9a-f]{8}$/.test(segments[1] ?? "")) {
90
+ return;
91
+ }
92
+ await rm(workspace.cwd, { recursive: true, force: true });
93
+ }
94
+ function parseXdgDocumentsDirectory(contents, home) {
95
+ for (const line of contents.split(/\r?\n/)) {
96
+ const match = line.match(/^\s*XDG_DOCUMENTS_DIR\s*=\s*"((?:\\.|[^"])*)"\s*(?:#.*)?$/);
97
+ if (!match)
98
+ continue;
99
+ const raw = match[1];
100
+ let expanded;
101
+ if (raw === "$HOME" || raw === "${HOME}") {
102
+ expanded = home;
103
+ }
104
+ else if (raw.startsWith("$HOME/")) {
105
+ expanded = join(home, decodeXdgPath(raw.slice("$HOME/".length)));
106
+ }
107
+ else if (raw.startsWith("${HOME}/")) {
108
+ expanded = join(home, decodeXdgPath(raw.slice("${HOME}/".length)));
109
+ }
110
+ else {
111
+ expanded = decodeXdgPath(raw);
112
+ }
113
+ if (!expanded.includes("\0") && isAbsolute(expanded))
114
+ return normalize(expanded);
115
+ }
116
+ return undefined;
117
+ }
118
+ function decodeXdgPath(value) {
119
+ return value.replace(/\\(.)/g, "$1");
120
+ }
@@ -0,0 +1,41 @@
1
+ import { type MachineSessionRecord } from "@rynx-ai/core";
2
+ import type { SessionItem } from "@rynx-ai/protocol";
3
+ import { type MachineSessionForkOperationRecord, type MachineSessionForkStorePort } from "@rynx-ai/server";
4
+ /**
5
+ * SQLite/resource-file implementation of the fork publication boundary.
6
+ *
7
+ * Provider context is prepared before {@link commit}; this store then publishes
8
+ * Session metadata, an independent canonical transcript, referenced committed
9
+ * resources, and the completed operation in one SQLite transaction. Resource
10
+ * files use deterministic target ids, so a crash before the transaction can be
11
+ * retried safely.
12
+ */
13
+ export declare class SqliteSessionForkStore implements MachineSessionForkStorePort {
14
+ private readonly resourceRoot;
15
+ constructor(resourceRoot?: string);
16
+ claim(input: {
17
+ operationId: string;
18
+ requestHash: string;
19
+ sourceSessionId: string;
20
+ title?: string;
21
+ }): MachineSessionForkOperationRecord;
22
+ commit(input: {
23
+ operationId: string;
24
+ target: MachineSessionRecord;
25
+ items: SessionItem[];
26
+ }): void;
27
+ publishNativeFork(input: {
28
+ sourceSessionId: string;
29
+ target: MachineSessionRecord;
30
+ items: SessionItem[];
31
+ }): void;
32
+ private publishTarget;
33
+ markError(operationId: string, error: string): void;
34
+ listReserved(): MachineSessionForkOperationRecord[];
35
+ hasReservedSource(sessionId: string): boolean;
36
+ markTargetDeleted(sessionId: string): void;
37
+ private read;
38
+ private copyResourceFile;
39
+ private sessionDirectory;
40
+ private resourcePath;
41
+ }
@@ -0,0 +1,288 @@
1
+ import { createHash } from "node:crypto";
2
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { newSessionId, newSessionItemId, normalizeSessionTitle, resolvedExecutionSnapshotSchema, rynxHome, sessionWorkspaceSnapshotSchema, } from "@rynx-ai/core";
5
+ import { MachineSessionServiceFailure, } from "@rynx-ai/server";
6
+ import { db } from "./db.js";
7
+ const FORK_COLS = [
8
+ "operation_id",
9
+ "request_hash",
10
+ "source_session_id",
11
+ "target_session_id",
12
+ "title",
13
+ "fork_position",
14
+ "state",
15
+ "error",
16
+ ].join(", ");
17
+ /**
18
+ * SQLite/resource-file implementation of the fork publication boundary.
19
+ *
20
+ * Provider context is prepared before {@link commit}; this store then publishes
21
+ * Session metadata, an independent canonical transcript, referenced committed
22
+ * resources, and the completed operation in one SQLite transaction. Resource
23
+ * files use deterministic target ids, so a crash before the transaction can be
24
+ * retried safely.
25
+ */
26
+ export class SqliteSessionForkStore {
27
+ resourceRoot;
28
+ constructor(resourceRoot = join(rynxHome(), "resources")) {
29
+ this.resourceRoot = resourceRoot;
30
+ }
31
+ claim(input) {
32
+ const existing = this.read(input.operationId);
33
+ if (existing) {
34
+ if (existing.request_hash !== input.requestHash ||
35
+ existing.source_session_id !== input.sourceSessionId) {
36
+ throw new MachineSessionServiceFailure("conflict", "operationId was already used for a different Session fork");
37
+ }
38
+ return toOperation(existing);
39
+ }
40
+ const now = new Date().toISOString();
41
+ let targetSessionId = newSessionId();
42
+ while (db().prepare("SELECT 1 FROM sessions WHERE id = ?").pluck().get(targetSessionId) ||
43
+ db().prepare("SELECT 1 FROM session_fork_operations WHERE target_session_id = ?")
44
+ .pluck().get(targetSessionId)) {
45
+ targetSessionId = newSessionId();
46
+ }
47
+ try {
48
+ db().prepare(`INSERT INTO session_fork_operations (
49
+ operation_id, request_hash, source_session_id, target_session_id,
50
+ title, state, created_at, updated_at
51
+ ) VALUES (?, ?, ?, ?, ?, 'reserved', ?, ?)`).run(input.operationId, input.requestHash, input.sourceSessionId, targetSessionId, normalizeSessionTitle(input.title) ?? null, now, now);
52
+ }
53
+ catch (error) {
54
+ const raced = this.read(input.operationId);
55
+ if (raced)
56
+ return this.claim(input);
57
+ const busy = db().prepare(`SELECT operation_id
58
+ FROM session_fork_operations
59
+ WHERE source_session_id = ? AND state = 'reserved'`).get(input.sourceSessionId);
60
+ if (busy) {
61
+ throw new MachineSessionServiceFailure("conflict", "source Session already has a fork in progress");
62
+ }
63
+ throw error;
64
+ }
65
+ return toOperation(this.read(input.operationId));
66
+ }
67
+ commit(input) {
68
+ const operation = this.read(input.operationId);
69
+ if (!operation) {
70
+ throw new MachineSessionServiceFailure("not_found", "Session fork operation not found");
71
+ }
72
+ if (operation.state === "target_deleted") {
73
+ throw new MachineSessionServiceFailure("conflict", "fork target was deleted");
74
+ }
75
+ if (operation.target_session_id !== input.target.id) {
76
+ throw new MachineSessionServiceFailure("conflict", "fork target identity changed");
77
+ }
78
+ if (operation.state === "completed")
79
+ return;
80
+ this.publishTarget({
81
+ sourceSessionId: operation.source_session_id,
82
+ target: input.target,
83
+ items: input.items,
84
+ operationId: input.operationId,
85
+ });
86
+ }
87
+ publishNativeFork(input) {
88
+ const existing = db().prepare("SELECT forked_from_session_id FROM sessions WHERE id = ?").get(input.target.id);
89
+ if (existing) {
90
+ if (existing.forked_from_session_id === input.sourceSessionId)
91
+ return;
92
+ throw new MachineSessionServiceFailure("conflict", "target Session already exists");
93
+ }
94
+ this.publishTarget(input);
95
+ }
96
+ publishTarget(input) {
97
+ const itemIds = new Map();
98
+ for (const item of input.items)
99
+ itemIds.set(item.id, newSessionItemId(item.type));
100
+ const resourceIds = referencedResourceIds(input.items);
101
+ const resourceMap = new Map();
102
+ const resources = resourceIds.map((resourceId) => {
103
+ const source = db().prepare(`SELECT * FROM session_resources
104
+ WHERE session_id = ? AND id = ? AND state = 'committed'`).get(input.sourceSessionId, resourceId);
105
+ if (!source) {
106
+ throw new MachineSessionServiceFailure("failed_precondition", `fork history references unavailable resource ${resourceId}`);
107
+ }
108
+ const targetId = deterministicId("res", input.target.id, source.id);
109
+ resourceMap.set(source.id, targetId);
110
+ const target = {
111
+ ...source,
112
+ id: targetId,
113
+ session_id: input.target.id,
114
+ client_upload_id: deterministicId("fork", input.target.id, source.client_upload_id),
115
+ upload_id: deterministicId("upl", input.target.id, source.upload_id),
116
+ message_item_id: source.message_item_id
117
+ ? itemIds.get(source.message_item_id) ?? null
118
+ : null,
119
+ };
120
+ this.copyResourceFile(source, target);
121
+ return target;
122
+ });
123
+ const clonedItems = input.items.map((item, position) => {
124
+ const remapped = remapItemResources(item, resourceMap);
125
+ return {
126
+ ...remapped,
127
+ id: itemIds.get(item.id),
128
+ sessionId: input.target.id,
129
+ position,
130
+ };
131
+ });
132
+ const conn = db();
133
+ conn.transaction(() => {
134
+ if (input.operationId) {
135
+ const current = this.read(input.operationId);
136
+ if (current?.state === "completed")
137
+ return;
138
+ if (!current || current.state !== "reserved") {
139
+ throw new MachineSessionServiceFailure("conflict", "Session fork state changed");
140
+ }
141
+ }
142
+ else {
143
+ const existing = conn.prepare("SELECT forked_from_session_id FROM sessions WHERE id = ?").get(input.target.id);
144
+ if (existing?.forked_from_session_id === input.sourceSessionId)
145
+ return;
146
+ if (existing) {
147
+ throw new MachineSessionServiceFailure("conflict", "target Session already exists");
148
+ }
149
+ }
150
+ conn.prepare(`INSERT INTO sessions (
151
+ id, workspace, execution, title, forked_from_session_id,
152
+ source, created_at, updated_at
153
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(input.target.id, JSON.stringify(sessionWorkspaceSnapshotSchema.parse(input.target.workspace)), JSON.stringify(resolvedExecutionSnapshotSchema.parse(input.target.execution)), normalizeSessionTitle(input.target.title) ?? null, input.target.forkedFromSessionId ?? null, input.target.source, input.target.createdAt, input.target.updatedAt ?? input.target.createdAt);
154
+ const insertResource = conn.prepare(`INSERT INTO session_resources (
155
+ id, session_id, client_upload_id, upload_id, filename, media_type,
156
+ byte_length, uploaded_bytes, sha256, width, height, state,
157
+ message_item_id, created_at, committed_at
158
+ ) VALUES (
159
+ @id, @session_id, @client_upload_id, @upload_id, @filename, @media_type,
160
+ @byte_length, @uploaded_bytes, @sha256, @width, @height, 'committed',
161
+ @message_item_id, @created_at, @committed_at
162
+ )`);
163
+ for (const resource of resources)
164
+ insertResource.run(resource);
165
+ const insertItem = conn.prepare(`INSERT INTO session_items (
166
+ id, session_id, position, response_id, type, status,
167
+ data, created_by, created_at
168
+ ) VALUES (
169
+ @id, @session_id, @position, @response_id, @type, @status,
170
+ @data, @created_by, @created_at
171
+ )`);
172
+ for (const item of clonedItems) {
173
+ insertItem.run({
174
+ id: item.id,
175
+ session_id: item.sessionId,
176
+ position: item.position,
177
+ response_id: item.responseId,
178
+ type: item.type,
179
+ status: item.status,
180
+ data: JSON.stringify(item.data),
181
+ created_by: item.createdBy ?? null,
182
+ created_at: item.createdAt,
183
+ });
184
+ }
185
+ if (input.operationId) {
186
+ const completedAt = new Date().toISOString();
187
+ conn.prepare(`UPDATE session_fork_operations
188
+ SET state = 'completed', fork_position = ?, error = NULL,
189
+ updated_at = ?, completed_at = ?
190
+ WHERE operation_id = ? AND state = 'reserved'`).run(clonedItems.at(-1)?.position ?? -1, completedAt, completedAt, input.operationId);
191
+ }
192
+ }).immediate();
193
+ }
194
+ markError(operationId, error) {
195
+ db().prepare(`UPDATE session_fork_operations
196
+ SET error = ?, updated_at = ?
197
+ WHERE operation_id = ? AND state = 'reserved'`).run(error.slice(0, 4_096), new Date().toISOString(), operationId);
198
+ }
199
+ listReserved() {
200
+ return db().prepare(`SELECT ${FORK_COLS}
201
+ FROM session_fork_operations
202
+ WHERE state = 'reserved'
203
+ ORDER BY created_at`).all().map(toOperation);
204
+ }
205
+ hasReservedSource(sessionId) {
206
+ return Boolean(db().prepare(`SELECT 1
207
+ FROM session_fork_operations
208
+ WHERE source_session_id = ? AND state = 'reserved'`).pluck().get(sessionId));
209
+ }
210
+ markTargetDeleted(sessionId) {
211
+ const now = new Date().toISOString();
212
+ db().prepare(`UPDATE session_fork_operations
213
+ SET state = 'target_deleted', updated_at = ?, deleted_at = ?
214
+ WHERE target_session_id = ? AND state IN ('reserved', 'completed')`).run(now, now, sessionId);
215
+ }
216
+ read(operationId) {
217
+ return db().prepare(`SELECT ${FORK_COLS}
218
+ FROM session_fork_operations
219
+ WHERE operation_id = ?`).get(operationId);
220
+ }
221
+ copyResourceFile(source, target) {
222
+ const sourcePath = this.resourcePath(source);
223
+ if (!existsSync(sourcePath)) {
224
+ throw new MachineSessionServiceFailure("failed_precondition", `fork resource file is missing: ${source.id}`);
225
+ }
226
+ const targetDir = this.sessionDirectory(target.session_id);
227
+ mkdirSync(targetDir, { recursive: true, mode: 0o700 });
228
+ chmodSync(targetDir, 0o700);
229
+ copyFileSync(sourcePath, this.resourcePath(target));
230
+ chmodSync(this.resourcePath(target), 0o600);
231
+ }
232
+ sessionDirectory(sessionId) {
233
+ const key = createHash("sha256").update(sessionId).digest("hex").slice(0, 32);
234
+ return join(this.resourceRoot, key);
235
+ }
236
+ resourcePath(row) {
237
+ return join(this.sessionDirectory(row.session_id), `${row.id}.${extension(row.media_type)}`);
238
+ }
239
+ }
240
+ function toOperation(row) {
241
+ return {
242
+ operationId: row.operation_id,
243
+ requestHash: row.request_hash,
244
+ sourceSessionId: row.source_session_id,
245
+ targetSessionId: row.target_session_id,
246
+ ...(row.title ? { title: row.title } : {}),
247
+ ...(row.fork_position === null ? {} : { forkPosition: row.fork_position }),
248
+ state: row.state,
249
+ ...(row.error ? { error: row.error } : {}),
250
+ };
251
+ }
252
+ function referencedResourceIds(items) {
253
+ const ids = new Set();
254
+ for (const item of items) {
255
+ if (item.type !== "message" || item.data.role !== "user")
256
+ continue;
257
+ for (const part of item.data.content) {
258
+ if (part.type === "input_image")
259
+ ids.add(part.resourceId);
260
+ }
261
+ }
262
+ return [...ids];
263
+ }
264
+ function remapItemResources(item, resources) {
265
+ const cloned = structuredClone(item);
266
+ if (cloned.type !== "message" || cloned.data.role !== "user")
267
+ return cloned;
268
+ cloned.data.content = cloned.data.content.map((part) => part.type === "input_image"
269
+ ? { ...part, resourceId: resources.get(part.resourceId) ?? part.resourceId }
270
+ : part);
271
+ return cloned;
272
+ }
273
+ function deterministicId(prefix, targetSessionId, sourceId) {
274
+ const digest = createHash("sha256")
275
+ .update(`${targetSessionId}\0${sourceId}`)
276
+ .digest("hex");
277
+ return `${prefix}_${digest.slice(0, 40)}`;
278
+ }
279
+ function extension(mediaType) {
280
+ switch (mediaType) {
281
+ case "image/png":
282
+ return "png";
283
+ case "image/jpeg":
284
+ return "jpg";
285
+ case "image/webp":
286
+ return "webp";
287
+ }
288
+ }
@@ -15,6 +15,7 @@ export interface EnsurePluginSessionLaunchResult {
15
15
  sessionId: string;
16
16
  disposition: "created" | "replayed";
17
17
  }
18
+ export type ReplayPluginSessionLaunchInput = Omit<EnsurePluginSessionLaunchInput, "session">;
18
19
  export declare class SessionLaunchOperationConflictError extends Error {
19
20
  readonly pluginId: string;
20
21
  readonly scopeId: string;
@@ -30,12 +31,23 @@ export declare class SessionLaunchOperationTargetDeletedError extends Error {
30
31
  readonly code = "SESSION_LAUNCH_OPERATION_TARGET_DELETED";
31
32
  constructor(pluginId: string, scopeId: string, operationId: string, sessionId: string);
32
33
  }
34
+ /**
35
+ * Read the durable idempotency fence before resolving mutable launch selectors
36
+ * such as a Project or Agent. A replay must not fail merely because the
37
+ * original template was edited or deleted after the Session snapshot existed.
38
+ *
39
+ * `undefined` means the caller must resolve snapshots and proceed through
40
+ * {@link ensurePluginSessionLaunch}; that function remains the atomic race
41
+ * winner when two first attempts arrive concurrently.
42
+ */
43
+ export declare function replayPluginSessionLaunch(input: ReplayPluginSessionLaunchInput): EnsurePluginSessionLaunchResult | undefined;
33
44
  /**
34
45
  * Atomically allocate a persisted machine session for one plugin operation.
35
46
  *
36
47
  * The durable operation row is the idempotency boundary across runner crashes,
37
- * daemon restarts, and remote lease re-delivery. It deliberately excludes any
38
- * Workspace concept: `scopeId` and `operationId` are opaque to the daemon.
48
+ * daemon restarts, and remote lease re-delivery. `scopeId` and `operationId`
49
+ * remain opaque; the Session row stores only the already-resolved workspace
50
+ * snapshot, never a Project identity.
39
51
  */
40
52
  export declare function ensurePluginSessionLaunch(input: EnsurePluginSessionLaunchInput): EnsurePluginSessionLaunchResult;
41
53
  export {};