chainlesschain 0.37.12 → 0.40.1

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 (48) hide show
  1. package/package.json +3 -2
  2. package/src/commands/agent.js +7 -1
  3. package/src/commands/ask.js +24 -9
  4. package/src/commands/chat.js +7 -1
  5. package/src/commands/cli-anything.js +266 -0
  6. package/src/commands/compliance.js +216 -0
  7. package/src/commands/dao.js +312 -0
  8. package/src/commands/dlp.js +278 -0
  9. package/src/commands/evomap.js +558 -0
  10. package/src/commands/hardening.js +230 -0
  11. package/src/commands/matrix.js +168 -0
  12. package/src/commands/nostr.js +185 -0
  13. package/src/commands/pqc.js +162 -0
  14. package/src/commands/scim.js +218 -0
  15. package/src/commands/serve.js +109 -0
  16. package/src/commands/siem.js +156 -0
  17. package/src/commands/social.js +480 -0
  18. package/src/commands/terraform.js +148 -0
  19. package/src/constants.js +1 -0
  20. package/src/index.js +60 -0
  21. package/src/lib/autonomous-agent.js +487 -0
  22. package/src/lib/cli-anything-bridge.js +379 -0
  23. package/src/lib/cli-context-engineering.js +472 -0
  24. package/src/lib/compliance-manager.js +290 -0
  25. package/src/lib/content-recommender.js +205 -0
  26. package/src/lib/dao-governance.js +296 -0
  27. package/src/lib/dlp-engine.js +304 -0
  28. package/src/lib/evomap-client.js +135 -0
  29. package/src/lib/evomap-federation.js +240 -0
  30. package/src/lib/evomap-governance.js +250 -0
  31. package/src/lib/evomap-manager.js +227 -0
  32. package/src/lib/git-integration.js +1 -1
  33. package/src/lib/hardening-manager.js +275 -0
  34. package/src/lib/llm-providers.js +14 -1
  35. package/src/lib/matrix-bridge.js +196 -0
  36. package/src/lib/nostr-bridge.js +195 -0
  37. package/src/lib/permanent-memory.js +370 -0
  38. package/src/lib/plan-mode.js +211 -0
  39. package/src/lib/pqc-manager.js +196 -0
  40. package/src/lib/scim-manager.js +212 -0
  41. package/src/lib/session-manager.js +38 -0
  42. package/src/lib/siem-exporter.js +137 -0
  43. package/src/lib/social-manager.js +283 -0
  44. package/src/lib/task-model-selector.js +232 -0
  45. package/src/lib/terraform-manager.js +201 -0
  46. package/src/lib/ws-server.js +474 -0
  47. package/src/repl/agent-repl.js +796 -41
  48. package/src/repl/chat-repl.js +14 -6
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Social Manager — contacts, friends, posts, chat messaging,
3
+ * and social statistics for CLI terminal use.
4
+ */
5
+
6
+ import crypto from "crypto";
7
+
8
+ /* ── In-memory stores ──────────────────────────────────────── */
9
+ const _contacts = new Map();
10
+ const _friends = new Map();
11
+ const _posts = new Map();
12
+ const _messages = new Map();
13
+
14
+ /* ── Schema ────────────────────────────────────────────────── */
15
+
16
+ export function ensureSocialTables(db) {
17
+ db.exec(`
18
+ CREATE TABLE IF NOT EXISTS social_contacts (
19
+ id TEXT PRIMARY KEY,
20
+ name TEXT NOT NULL,
21
+ did TEXT,
22
+ email TEXT,
23
+ notes TEXT,
24
+ created_at TEXT DEFAULT (datetime('now'))
25
+ )
26
+ `);
27
+ db.exec(`
28
+ CREATE TABLE IF NOT EXISTS social_friends (
29
+ id TEXT PRIMARY KEY,
30
+ contact_id TEXT NOT NULL,
31
+ status TEXT DEFAULT 'pending',
32
+ created_at TEXT DEFAULT (datetime('now'))
33
+ )
34
+ `);
35
+ db.exec(`
36
+ CREATE TABLE IF NOT EXISTS social_posts (
37
+ id TEXT PRIMARY KEY,
38
+ author TEXT,
39
+ content TEXT NOT NULL,
40
+ likes INTEGER DEFAULT 0,
41
+ created_at TEXT DEFAULT (datetime('now'))
42
+ )
43
+ `);
44
+ db.exec(`
45
+ CREATE TABLE IF NOT EXISTS social_messages (
46
+ id TEXT PRIMARY KEY,
47
+ thread_id TEXT,
48
+ sender TEXT,
49
+ recipient TEXT,
50
+ content TEXT NOT NULL,
51
+ read INTEGER DEFAULT 0,
52
+ created_at TEXT DEFAULT (datetime('now'))
53
+ )
54
+ `);
55
+ }
56
+
57
+ /* ── Contacts ──────────────────────────────────────────────── */
58
+
59
+ export function addContact(db, name, did, email, notes) {
60
+ if (!name) throw new Error("Contact name is required");
61
+
62
+ const id = crypto.randomUUID();
63
+ const now = new Date().toISOString();
64
+
65
+ const contact = {
66
+ id,
67
+ name,
68
+ did: did || null,
69
+ email: email || null,
70
+ notes: notes || "",
71
+ createdAt: now,
72
+ };
73
+
74
+ _contacts.set(id, contact);
75
+
76
+ db.prepare(
77
+ `INSERT INTO social_contacts (id, name, did, email, notes, created_at)
78
+ VALUES (?, ?, ?, ?, ?, ?)`,
79
+ ).run(id, name, contact.did, contact.email, contact.notes, now);
80
+
81
+ return contact;
82
+ }
83
+
84
+ export function listContacts() {
85
+ return [..._contacts.values()];
86
+ }
87
+
88
+ export function deleteContact(db, contactId) {
89
+ const contact = _contacts.get(contactId);
90
+ if (!contact) throw new Error(`Contact not found: ${contactId}`);
91
+
92
+ _contacts.delete(contactId);
93
+ _friends.delete(contactId);
94
+
95
+ db.prepare(`DELETE FROM social_contacts WHERE id = ?`).run(contactId);
96
+
97
+ return { success: true, contactId };
98
+ }
99
+
100
+ export function showContact(contactId) {
101
+ const contact = _contacts.get(contactId);
102
+ if (!contact) throw new Error(`Contact not found: ${contactId}`);
103
+ return contact;
104
+ }
105
+
106
+ /* ── Friends ───────────────────────────────────────────────── */
107
+
108
+ export function addFriend(db, contactId) {
109
+ if (!_contacts.has(contactId)) {
110
+ throw new Error(`Contact not found: ${contactId}`);
111
+ }
112
+
113
+ const id = crypto.randomUUID();
114
+ const now = new Date().toISOString();
115
+
116
+ const friend = { id, contactId, status: "pending", createdAt: now };
117
+ _friends.set(contactId, friend);
118
+
119
+ db.prepare(
120
+ `INSERT INTO social_friends (id, contact_id, status, created_at)
121
+ VALUES (?, ?, ?, ?)`,
122
+ ).run(id, contactId, "pending", now);
123
+
124
+ return friend;
125
+ }
126
+
127
+ export function listFriends() {
128
+ return [..._friends.values()];
129
+ }
130
+
131
+ export function removeFriend(db, contactId) {
132
+ if (!_friends.has(contactId)) {
133
+ throw new Error(`Friend not found for contact: ${contactId}`);
134
+ }
135
+
136
+ _friends.delete(contactId);
137
+
138
+ db.prepare(`DELETE FROM social_friends WHERE contact_id = ?`).run(contactId);
139
+
140
+ return { success: true, contactId };
141
+ }
142
+
143
+ export function pendingRequests() {
144
+ return [..._friends.values()].filter((f) => f.status === "pending");
145
+ }
146
+
147
+ export function acceptFriend(contactId) {
148
+ const friend = _friends.get(contactId);
149
+ if (!friend) throw new Error(`Friend request not found: ${contactId}`);
150
+ friend.status = "accepted";
151
+ return friend;
152
+ }
153
+
154
+ /* ── Posts ──────────────────────────────────────────────────── */
155
+
156
+ export function publishPost(db, content, author) {
157
+ if (!content) throw new Error("Post content is required");
158
+
159
+ const id = crypto.randomUUID();
160
+ const now = new Date().toISOString();
161
+
162
+ const post = {
163
+ id,
164
+ author: author || "cli-user",
165
+ content,
166
+ likes: 0,
167
+ createdAt: now,
168
+ };
169
+
170
+ _posts.set(id, post);
171
+
172
+ db.prepare(
173
+ `INSERT INTO social_posts (id, author, content, likes, created_at)
174
+ VALUES (?, ?, ?, ?, ?)`,
175
+ ).run(id, post.author, content, 0, now);
176
+
177
+ return post;
178
+ }
179
+
180
+ export function listPosts(filter = {}) {
181
+ let posts = [..._posts.values()];
182
+ if (filter.author) {
183
+ posts = posts.filter((p) => p.author === filter.author);
184
+ }
185
+ const limit = filter.limit || 50;
186
+ return posts.slice(0, limit);
187
+ }
188
+
189
+ export function likePost(db, postId) {
190
+ const post = _posts.get(postId);
191
+ if (!post) throw new Error(`Post not found: ${postId}`);
192
+
193
+ post.likes++;
194
+
195
+ db.prepare(`UPDATE social_posts SET likes = ? WHERE id = ?`).run(
196
+ post.likes,
197
+ postId,
198
+ );
199
+
200
+ return post;
201
+ }
202
+
203
+ /* ── Chat Messaging ───────────────────────────────────────── */
204
+
205
+ export function sendChatMessage(db, recipient, content, sender) {
206
+ if (!recipient) throw new Error("Recipient is required");
207
+ if (!content) throw new Error("Message content is required");
208
+
209
+ const id = crypto.randomUUID();
210
+ const now = new Date().toISOString();
211
+ const senderName = sender || "cli-user";
212
+
213
+ // Thread ID is sorted pair
214
+ const pair = [senderName, recipient].sort();
215
+ const threadId = `${pair[0]}:${pair[1]}`;
216
+
217
+ const message = {
218
+ id,
219
+ threadId,
220
+ sender: senderName,
221
+ recipient,
222
+ content,
223
+ read: false,
224
+ createdAt: now,
225
+ };
226
+
227
+ _messages.set(id, message);
228
+
229
+ db.prepare(
230
+ `INSERT INTO social_messages (id, thread_id, sender, recipient, content, read, created_at)
231
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
232
+ ).run(id, threadId, senderName, recipient, content, 0, now);
233
+
234
+ return message;
235
+ }
236
+
237
+ export function getChatMessages(threadId, filter = {}) {
238
+ if (!threadId) throw new Error("Thread ID is required");
239
+
240
+ let messages = [..._messages.values()].filter((m) => m.threadId === threadId);
241
+ const limit = filter.limit || 50;
242
+ return messages.slice(0, limit);
243
+ }
244
+
245
+ export function getChatThreads() {
246
+ const threads = new Map();
247
+ for (const msg of _messages.values()) {
248
+ if (!threads.has(msg.threadId)) {
249
+ threads.set(msg.threadId, {
250
+ threadId: msg.threadId,
251
+ lastMessage: msg,
252
+ messageCount: 0,
253
+ });
254
+ }
255
+ const thread = threads.get(msg.threadId);
256
+ thread.messageCount++;
257
+ thread.lastMessage = msg;
258
+ }
259
+ return [...threads.values()];
260
+ }
261
+
262
+ /* ── Stats ─────────────────────────────────────────────────── */
263
+
264
+ export function getSocialStats() {
265
+ return {
266
+ contacts: _contacts.size,
267
+ friends: _friends.size,
268
+ posts: _posts.size,
269
+ messages: _messages.size,
270
+ pendingRequests: [..._friends.values()].filter(
271
+ (f) => f.status === "pending",
272
+ ).length,
273
+ };
274
+ }
275
+
276
+ /* ── Reset (for testing) ───────────────────────────────────── */
277
+
278
+ export function _resetState() {
279
+ _contacts.clear();
280
+ _friends.clear();
281
+ _posts.clear();
282
+ _messages.clear();
283
+ }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Task-based intelligent model selector for CLI
3
+ *
4
+ * Detects task type from user messages and recommends the best model
5
+ * for each LLM provider. Enables automatic model switching based on
6
+ * what the user is trying to accomplish.
7
+ */
8
+
9
+ /**
10
+ * Task types supported by the selector
11
+ */
12
+ export const TaskType = {
13
+ CHAT: "chat",
14
+ CODE: "code",
15
+ REASONING: "reasoning",
16
+ FAST: "fast",
17
+ TRANSLATE: "translate",
18
+ CREATIVE: "creative",
19
+ };
20
+
21
+ /**
22
+ * Task type → recommended model per provider
23
+ * Each provider maps to the best model for that task type.
24
+ */
25
+ const TASK_MODEL_MAP = {
26
+ [TaskType.CHAT]: {
27
+ volcengine: "doubao-seed-1-6-flash-250828",
28
+ openai: "gpt-4o-mini",
29
+ anthropic: "claude-sonnet-4-6",
30
+ deepseek: "deepseek-chat",
31
+ dashscope: "qwen-plus",
32
+ gemini: "gemini-2.0-flash",
33
+ mistral: "mistral-medium-latest",
34
+ ollama: "qwen2:7b",
35
+ },
36
+ [TaskType.CODE]: {
37
+ volcengine: "doubao-seed-code",
38
+ openai: "gpt-4o",
39
+ anthropic: "claude-sonnet-4-6",
40
+ deepseek: "deepseek-coder",
41
+ dashscope: "qwen-max",
42
+ gemini: "gemini-2.0-pro",
43
+ mistral: "mistral-large-latest",
44
+ ollama: "codellama:7b",
45
+ },
46
+ [TaskType.REASONING]: {
47
+ volcengine: "doubao-seed-1-6-251015",
48
+ openai: "o1",
49
+ anthropic: "claude-opus-4-6",
50
+ deepseek: "deepseek-reasoner",
51
+ dashscope: "qwen-max",
52
+ gemini: "gemini-2.0-pro",
53
+ mistral: "mistral-large-latest",
54
+ ollama: "qwen2:7b",
55
+ },
56
+ [TaskType.FAST]: {
57
+ volcengine: "doubao-seed-1-6-lite-251015",
58
+ openai: "gpt-4o-mini",
59
+ anthropic: "claude-haiku-4-5-20251001",
60
+ deepseek: "deepseek-chat",
61
+ dashscope: "qwen-turbo",
62
+ gemini: "gemini-2.0-flash",
63
+ mistral: "mistral-small-latest",
64
+ ollama: "qwen2:7b",
65
+ },
66
+ [TaskType.TRANSLATE]: {
67
+ volcengine: "doubao-seed-1-6-251015",
68
+ openai: "gpt-4o",
69
+ anthropic: "claude-sonnet-4-6",
70
+ deepseek: "deepseek-chat",
71
+ dashscope: "qwen-plus",
72
+ gemini: "gemini-2.0-flash",
73
+ mistral: "mistral-large-latest",
74
+ ollama: "qwen2:7b",
75
+ },
76
+ [TaskType.CREATIVE]: {
77
+ volcengine: "doubao-seed-1-6-251015",
78
+ openai: "gpt-4o",
79
+ anthropic: "claude-opus-4-6",
80
+ deepseek: "deepseek-chat",
81
+ dashscope: "qwen-max",
82
+ gemini: "gemini-2.0-pro",
83
+ mistral: "mistral-large-latest",
84
+ ollama: "qwen2:7b",
85
+ },
86
+ };
87
+
88
+ /**
89
+ * Task type display names (Chinese + English)
90
+ */
91
+ const TASK_NAMES = {
92
+ [TaskType.CHAT]: "日常对话",
93
+ [TaskType.CODE]: "代码任务",
94
+ [TaskType.REASONING]: "复杂推理",
95
+ [TaskType.FAST]: "快速响应",
96
+ [TaskType.TRANSLATE]: "翻译任务",
97
+ [TaskType.CREATIVE]: "创意写作",
98
+ };
99
+
100
+ /**
101
+ * Keyword patterns for detecting task type from user message.
102
+ * Each pattern is [regex, taskType, priority].
103
+ * Higher priority wins when multiple patterns match.
104
+ */
105
+ const TASK_PATTERNS = [
106
+ // Code patterns (priority 10) — English with word boundaries
107
+ [
108
+ /\b(code|coding|program|function|class|bug|debug|refactor|implement)\b/i,
109
+ TaskType.CODE,
110
+ 10,
111
+ ],
112
+ [
113
+ /\b(javascript|typescript|python|java|rust|go|c\+\+|sql|html|css|react|vue|node|npm|git|api|endpoint|database)\b/i,
114
+ TaskType.CODE,
115
+ 10,
116
+ ],
117
+ [/```[\s\S]*```/, TaskType.CODE, 10],
118
+ // Code patterns — Chinese (no \b, Chinese chars are not word-boundary compatible)
119
+ [
120
+ /(代码|编程|函数|调试|重构|实现|写[一个]*[代码函数方法])/,
121
+ TaskType.CODE,
122
+ 10,
123
+ ],
124
+
125
+ // Reasoning patterns (priority 8)
126
+ [
127
+ /\b(analyze|reason|explain why|prove|compare|evaluate)\b/i,
128
+ TaskType.REASONING,
129
+ 8,
130
+ ],
131
+ [/\b(step.by.step|think.*through)\b/i, TaskType.REASONING, 8],
132
+ [
133
+ /(分析|推理|解释为什么|证明|比较|评估|深度思考|逻辑|逐步|一步一步)/,
134
+ TaskType.REASONING,
135
+ 8,
136
+ ],
137
+
138
+ // Translation patterns (priority 9)
139
+ [/\b(translate|translation|translate.*to)\b/i, TaskType.TRANSLATE, 9],
140
+ [/(翻译|转换.*语言|英译中|中译英)/, TaskType.TRANSLATE, 9],
141
+
142
+ // Creative patterns (priority 7)
143
+ [
144
+ /\b(write|create|compose|story|poem|essay|blog|article)\b/i,
145
+ TaskType.CREATIVE,
146
+ 7,
147
+ ],
148
+ [/(写[一篇]*.*[故事诗歌文章博客]|创作|小说|剧本)/, TaskType.CREATIVE, 7],
149
+
150
+ // Fast patterns (priority 5)
151
+ [/\b(quick|brief|short)\b/i, TaskType.FAST, 5],
152
+ [/(简短|快速|简单回答|一句话)/, TaskType.FAST, 5],
153
+ ];
154
+
155
+ /**
156
+ * Detect the task type from a user message using keyword matching.
157
+ *
158
+ * @param {string} message - User's input message
159
+ * @returns {{ taskType: string, confidence: number, name: string }}
160
+ */
161
+ export function detectTaskType(message) {
162
+ if (!message || typeof message !== "string") {
163
+ return {
164
+ taskType: TaskType.CHAT,
165
+ confidence: 0,
166
+ name: TASK_NAMES[TaskType.CHAT],
167
+ };
168
+ }
169
+
170
+ let bestMatch = null;
171
+ let bestPriority = -1;
172
+ let matchCount = 0;
173
+
174
+ for (const [pattern, taskType, priority] of TASK_PATTERNS) {
175
+ if (pattern.test(message)) {
176
+ matchCount++;
177
+ if (priority > bestPriority) {
178
+ bestPriority = priority;
179
+ bestMatch = taskType;
180
+ }
181
+ }
182
+ }
183
+
184
+ if (!bestMatch) {
185
+ return {
186
+ taskType: TaskType.CHAT,
187
+ confidence: 0,
188
+ name: TASK_NAMES[TaskType.CHAT],
189
+ };
190
+ }
191
+
192
+ // Confidence based on match count and priority
193
+ const confidence = Math.min(1, matchCount * 0.3 + bestPriority * 0.07);
194
+
195
+ return {
196
+ taskType: bestMatch,
197
+ confidence,
198
+ name: TASK_NAMES[bestMatch],
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Select the best model for a given provider and task type.
204
+ *
205
+ * @param {string} provider - LLM provider name
206
+ * @param {string} taskType - Task type from TaskType enum
207
+ * @returns {string|null} Model ID, or null if no recommendation
208
+ */
209
+ export function selectModelForTask(provider, taskType) {
210
+ const taskMap = TASK_MODEL_MAP[taskType];
211
+ if (!taskMap) return null;
212
+ return taskMap[provider] || null;
213
+ }
214
+
215
+ /**
216
+ * Get a human-readable task name.
217
+ *
218
+ * @param {string} taskType - Task type
219
+ * @returns {string}
220
+ */
221
+ export function getTaskName(taskType) {
222
+ return TASK_NAMES[taskType] || taskType;
223
+ }
224
+
225
+ /**
226
+ * Get all supported task types.
227
+ *
228
+ * @returns {Object} TaskType enum
229
+ */
230
+ export function getTaskTypes() {
231
+ return { ...TaskType };
232
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Terraform Manager — workspace management, plan/apply runs,
3
+ * and infrastructure-as-code operations.
4
+ */
5
+
6
+ import crypto from "crypto";
7
+
8
+ /* ── In-memory stores ──────────────────────────────────────── */
9
+ const _workspaces = new Map();
10
+ const _runs = new Map();
11
+
12
+ const RUN_STATUS = {
13
+ PENDING: "pending",
14
+ PLANNING: "planning",
15
+ PLANNED: "planned",
16
+ APPLYING: "applying",
17
+ APPLIED: "applied",
18
+ ERRORED: "errored",
19
+ };
20
+
21
+ const RUN_TYPES = { PLAN: "plan", APPLY: "apply", DESTROY: "destroy" };
22
+ const WORKSPACE_STATUS = {
23
+ ACTIVE: "active",
24
+ LOCKED: "locked",
25
+ ARCHIVED: "archived",
26
+ };
27
+
28
+ /* ── Schema ────────────────────────────────────────────────── */
29
+
30
+ export function ensureTerraformTables(db) {
31
+ db.exec(`
32
+ CREATE TABLE IF NOT EXISTS terraform_workspaces (
33
+ id TEXT PRIMARY KEY,
34
+ name TEXT NOT NULL,
35
+ description TEXT,
36
+ terraform_version TEXT DEFAULT '1.9.0',
37
+ working_directory TEXT,
38
+ auto_apply INTEGER DEFAULT 0,
39
+ status TEXT DEFAULT 'active',
40
+ last_run_id TEXT,
41
+ last_run_at TEXT,
42
+ state_version INTEGER DEFAULT 0,
43
+ variables TEXT,
44
+ providers TEXT,
45
+ created_at TEXT DEFAULT (datetime('now'))
46
+ )
47
+ `);
48
+ db.exec(`
49
+ CREATE TABLE IF NOT EXISTS terraform_runs (
50
+ id TEXT PRIMARY KEY,
51
+ workspace_id TEXT,
52
+ run_type TEXT DEFAULT 'plan',
53
+ status TEXT DEFAULT 'pending',
54
+ plan_output TEXT,
55
+ apply_output TEXT,
56
+ resources_added INTEGER DEFAULT 0,
57
+ resources_changed INTEGER DEFAULT 0,
58
+ resources_destroyed INTEGER DEFAULT 0,
59
+ triggered_by TEXT,
60
+ started_at TEXT,
61
+ completed_at TEXT,
62
+ error_message TEXT,
63
+ created_at TEXT DEFAULT (datetime('now'))
64
+ )
65
+ `);
66
+ }
67
+
68
+ /* ── Workspace Management ─────────────────────────────────── */
69
+
70
+ export function listWorkspaces(filter = {}) {
71
+ let workspaces = [..._workspaces.values()];
72
+ if (filter.status) {
73
+ workspaces = workspaces.filter((w) => w.status === filter.status);
74
+ }
75
+ const limit = filter.limit || 50;
76
+ return workspaces.slice(0, limit);
77
+ }
78
+
79
+ export function createWorkspace(db, name, opts = {}) {
80
+ if (!name) throw new Error("Workspace name is required");
81
+
82
+ const id = crypto.randomUUID();
83
+ const now = new Date().toISOString();
84
+
85
+ const workspace = {
86
+ id,
87
+ name,
88
+ description: opts.description || "",
89
+ terraformVersion: opts.terraformVersion || "1.9.0",
90
+ workingDirectory: opts.workingDirectory || ".",
91
+ autoApply: opts.autoApply || false,
92
+ status: WORKSPACE_STATUS.ACTIVE,
93
+ lastRunId: null,
94
+ lastRunAt: null,
95
+ stateVersion: 0,
96
+ variables: opts.variables || {},
97
+ providers: opts.providers || ["hashicorp/aws"],
98
+ createdAt: now,
99
+ };
100
+
101
+ _workspaces.set(id, workspace);
102
+
103
+ db.prepare(
104
+ `INSERT INTO terraform_workspaces (id, name, description, terraform_version, working_directory, auto_apply, status, last_run_id, last_run_at, state_version, variables, providers, created_at)
105
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
106
+ ).run(
107
+ id,
108
+ name,
109
+ workspace.description,
110
+ workspace.terraformVersion,
111
+ workspace.workingDirectory,
112
+ workspace.autoApply ? 1 : 0,
113
+ workspace.status,
114
+ null,
115
+ null,
116
+ 0,
117
+ JSON.stringify(workspace.variables),
118
+ JSON.stringify(workspace.providers),
119
+ now,
120
+ );
121
+
122
+ return workspace;
123
+ }
124
+
125
+ /* ── Run Management ───────────────────────────────────────── */
126
+
127
+ export function planRun(db, workspaceId, opts = {}) {
128
+ const workspace = _workspaces.get(workspaceId);
129
+ if (!workspace) throw new Error(`Workspace not found: ${workspaceId}`);
130
+
131
+ const id = crypto.randomUUID();
132
+ const now = new Date().toISOString();
133
+ const runType = opts.runType || RUN_TYPES.PLAN;
134
+
135
+ // Simulate resource changes
136
+ const added = Math.floor(Math.random() * 5) + 1;
137
+ const changed = Math.floor(Math.random() * 3);
138
+ const destroyed =
139
+ runType === RUN_TYPES.DESTROY ? Math.floor(Math.random() * 5) + 1 : 0;
140
+
141
+ const run = {
142
+ id,
143
+ workspaceId,
144
+ runType,
145
+ status: RUN_STATUS.PLANNED,
146
+ planOutput: `Plan: ${added} to add, ${changed} to change, ${destroyed} to destroy.`,
147
+ applyOutput: null,
148
+ resourcesAdded: added,
149
+ resourcesChanged: changed,
150
+ resourcesDestroyed: destroyed,
151
+ triggeredBy: opts.triggeredBy || "cli-user",
152
+ startedAt: now,
153
+ completedAt: now,
154
+ errorMessage: null,
155
+ createdAt: now,
156
+ };
157
+
158
+ _runs.set(id, run);
159
+
160
+ workspace.lastRunId = id;
161
+ workspace.lastRunAt = now;
162
+ workspace.stateVersion++;
163
+
164
+ db.prepare(
165
+ `INSERT INTO terraform_runs (id, workspace_id, run_type, status, plan_output, apply_output, resources_added, resources_changed, resources_destroyed, triggered_by, started_at, completed_at, error_message, created_at)
166
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
167
+ ).run(
168
+ id,
169
+ workspaceId,
170
+ runType,
171
+ run.status,
172
+ run.planOutput,
173
+ null,
174
+ run.resourcesAdded,
175
+ run.resourcesChanged,
176
+ run.resourcesDestroyed,
177
+ run.triggeredBy,
178
+ now,
179
+ now,
180
+ null,
181
+ now,
182
+ );
183
+
184
+ return run;
185
+ }
186
+
187
+ export function listRuns(filter = {}) {
188
+ let runs = [..._runs.values()];
189
+ if (filter.workspaceId) {
190
+ runs = runs.filter((r) => r.workspaceId === filter.workspaceId);
191
+ }
192
+ const limit = filter.limit || 20;
193
+ return runs.slice(0, limit);
194
+ }
195
+
196
+ /* ── Reset (for testing) ───────────────────────────────────── */
197
+
198
+ export function _resetState() {
199
+ _workspaces.clear();
200
+ _runs.clear();
201
+ }