@game_ryo/lsji 0.1.1 → 0.3.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.
@@ -0,0 +1,210 @@
1
+ /**
2
+ * HITL Approval Gate
3
+ *
4
+ * Human-in-the-Loop approval workflow for sensitive operations.
5
+ * Integrates with storage, notifier, and provides timeout/escalation.
6
+ */
7
+
8
+ import { v4 as uuidv4 } from 'uuid';
9
+ import { ApprovalStore, createApprovalStore } from './store.js';
10
+ import { Notifier } from './notifier.js';
11
+
12
+ /**
13
+ * Approval request options
14
+ * @typedef {Object} ApprovalRequest
15
+ * @property {string} action - Action identifier (e.g., 'send_email', 'charge_payment')
16
+ * @property {Object} context - Context data for human review
17
+ * @property {string} requester - Who is requesting approval
18
+ * @property {number} [timeout] - Timeout in ms (default: 5 min)
19
+ * @property {Array<string>} [channels] - Notification channels
20
+ * @property {Object} [metadata] - Additional metadata
21
+ */
22
+
23
+ /**
24
+ * Approval result
25
+ * @typedef {Object} ApprovalResult
26
+ * @property {string} id - Approval ID
27
+ * @property {string} status - 'approved' | 'rejected' | 'expired'
28
+ * @property {string} [decider] - Who decided
29
+ * @property {string} [reason] - Reason for decision
30
+ * @property {Date} decidedAt - When decision was made
31
+ */
32
+
33
+ /**
34
+ * Approval Gate - Manages human approval workflows
35
+ */
36
+ export class ApprovalGate {
37
+ constructor({ store, notifier, defaultTimeout = 300000 } = {}) {
38
+ this.store = store;
39
+ this.notifier = notifier;
40
+ this.defaultTimeout = defaultTimeout;
41
+ this.pendingApprovals = new Map(); // id -> { resolve, reject, timeout }
42
+ }
43
+
44
+ /**
45
+ * Request approval for an action
46
+ * Returns a promise that resolves when approved, rejects when rejected/expired
47
+ */
48
+ async requestApproval(request) {
49
+ const id = uuidv4();
50
+ const createdAt = new Date();
51
+ const expiresAt = new Date(createdAt.getTime() + (request.timeout || this.defaultTimeout));
52
+
53
+ const approval = {
54
+ id,
55
+ action: request.action,
56
+ context: request.context,
57
+ requester: request.requester,
58
+ status: 'pending',
59
+ createdAt: createdAt.toISOString(),
60
+ expiresAt: expiresAt.toISOString(),
61
+ metadata: request.metadata,
62
+ };
63
+
64
+ // Store in database
65
+ await this.store.createApproval(approval);
66
+
67
+ // Notify via configured channels
68
+ await this.notifier.notify(approval, request.channels);
69
+
70
+ // Return promise that resolves when decision is made
71
+ return new Promise((resolve, reject) => {
72
+ const timeoutHandle = setTimeout(async () => {
73
+ // Check if still pending (not decided yet)
74
+ const current = await this.store.getApproval(id);
75
+ if (current && current.status === 'pending') {
76
+ await this.store.updateStatus(id, 'expired', null, 'Timeout');
77
+ this.pendingApprovals.delete(id);
78
+ reject(new Error(`Approval timeout: ${id}`));
79
+ }
80
+ }, request.timeout || this.defaultTimeout);
81
+
82
+ this.pendingApprovals.set(id, { resolve, reject, timeout: timeoutHandle });
83
+ });
84
+ }
85
+
86
+ /**
87
+ * Approve a pending request
88
+ */
89
+ async approve(id, { decider, reason } = {}) {
90
+ const result = await this.store.approve(id, { decider, reason });
91
+
92
+ // Resolve pending promise
93
+ const pending = this.pendingApprovals.get(id);
94
+ if (pending) {
95
+ clearTimeout(pending.timeout);
96
+ pending.resolve(result);
97
+ this.pendingApprovals.delete(id);
98
+ }
99
+
100
+ // Notify
101
+ await this.notifier.notify({ ...result, status: 'approved' });
102
+
103
+ return result;
104
+ }
105
+
106
+ /**
107
+ * Reject a pending request
108
+ */
109
+ async reject(id, { decider, reason } = {}) {
110
+ const result = await this.store.reject(id, { decider, reason });
111
+
112
+ // Reject pending promise
113
+ const pending = this.pendingApprovals.get(id);
114
+ if (pending) {
115
+ clearTimeout(pending.timeout);
116
+ pending.reject(new Error(`Approval rejected: ${reason || 'No reason provided'}`));
117
+ this.pendingApprovals.delete(id);
118
+ }
119
+
120
+ // Notify
121
+ await this.notifier.notify({ ...result, status: 'rejected' });
122
+
123
+ return result;
124
+ }
125
+
126
+ /**
127
+ * Get approval status
128
+ */
129
+ async getApproval(id) {
130
+ return this.store.getApproval(id);
131
+ }
132
+
133
+ /**
134
+ * Get all pending approvals
135
+ */
136
+ async getPendingApprovals(limit = 100) {
137
+ return this.store.getPendingApprovals(limit);
138
+ }
139
+
140
+ /**
141
+ * Get approvals by requester
142
+ */
143
+ async getApprovalsByRequester(requester, limit = 100) {
144
+ return this.store.getApprovalsByRequester(requester, limit);
145
+ }
146
+
147
+ /**
148
+ * Clean up expired approvals
149
+ */
150
+ async cleanup() {
151
+ await this.store.cleanupExpired();
152
+ }
153
+
154
+ /**
155
+ * Check if action requires approval
156
+ * Override this to define custom approval rules
157
+ */
158
+ requiresApproval(action, context = {}) {
159
+ // Default: require approval for sensitive actions
160
+ const sensitiveActions = [
161
+ 'send_email',
162
+ 'charge_payment',
163
+ 'delete_data',
164
+ 'modify_user',
165
+ 'deploy_code',
166
+ 'run_sql',
167
+ 'api_call',
168
+ 'file_write',
169
+ 'ssh_command',
170
+ ];
171
+
172
+ return sensitiveActions.includes(action);
173
+ }
174
+
175
+ /**
176
+ * Execute action with automatic approval if needed
177
+ */
178
+ async executeWithApproval(action, context, executor, requester = 'agent') {
179
+ if (!this.requiresApproval(action, context)) {
180
+ return executor();
181
+ }
182
+
183
+ // Request approval
184
+ const approval = await this.requestApproval({
185
+ action,
186
+ context,
187
+ requester,
188
+ });
189
+
190
+ if (approval.status === 'approved') {
191
+ return executor();
192
+ } else {
193
+ throw new Error(`Action ${action} was ${approval.status}`);
194
+ }
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Create approval gate from config
200
+ */
201
+ export async function createApprovalGate(config = {}) {
202
+ const store = await createApprovalStore(config.store || {});
203
+ const notifier = new Notifier(config.notifier || {});
204
+
205
+ return new ApprovalGate({
206
+ store,
207
+ notifier,
208
+ defaultTimeout: config.defaultTimeout,
209
+ });
210
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * HITL (Human-in-the-Loop) System
3
+ *
4
+ * Exports all HITL components:
5
+ * - ApprovalStore: Persistent storage for approvals
6
+ * - Notifier: Multi-channel notifications
7
+ * - ApprovalGate: Approval workflow management
8
+ */
9
+
10
+ export { ApprovalStore, createApprovalStore } from './store.js';
11
+ export { Notifier, NotificationChannel, createNotifier } from './notifier.js';
12
+ export { ApprovalGate, createApprovalGate } from './approval-gate.js';
@@ -0,0 +1,151 @@
1
+ /**
2
+ * HITL Notifier
3
+ *
4
+ * Multi-channel notification system for approval requests.
5
+ * Supports console, webhook, email, and custom handlers.
6
+ */
7
+
8
+ /**
9
+ * Notification channels
10
+ */
11
+ export const NotificationChannel = {
12
+ CONSOLE: 'console',
13
+ WEBHOOK: 'webhook',
14
+ EMAIL: 'email',
15
+ CUSTOM: 'custom',
16
+ };
17
+
18
+ /**
19
+ * Notifier - Sends approval notifications via multiple channels
20
+ */
21
+ export class Notifier {
22
+ constructor(config = {}) {
23
+ this.channels = new Map();
24
+ this.defaultChannels = config.defaultChannels || [NotificationChannel.CONSOLE];
25
+
26
+ // Register built-in channels
27
+ this.registerChannel(NotificationChannel.CONSOLE, this.notifyConsole.bind(this));
28
+
29
+ if (config.webhookUrl) {
30
+ this.registerChannel(NotificationChannel.WEBHOOK, this.notifyWebhook.bind(this, config.webhookUrl));
31
+ }
32
+
33
+ if (config.emailConfig) {
34
+ this.registerChannel(NotificationChannel.EMAIL, this.notifyEmail.bind(this, config.emailConfig));
35
+ }
36
+
37
+ // Register custom channels
38
+ if (config.customChannels) {
39
+ for (const [name, handler] of Object.entries(config.customChannels)) {
40
+ this.registerChannel(name, handler);
41
+ }
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Register a notification channel
47
+ */
48
+ registerChannel(name, handler) {
49
+ this.channels.set(name, handler);
50
+ }
51
+
52
+ /**
53
+ * Unregister a channel
54
+ */
55
+ unregisterChannel(name) {
56
+ this.channels.delete(name);
57
+ }
58
+
59
+ /**
60
+ * Send notification to all configured channels
61
+ */
62
+ async notify(approval, channels = this.defaultChannels) {
63
+ const results = [];
64
+
65
+ for (const channel of channels) {
66
+ const handler = this.channels.get(channel);
67
+ if (handler) {
68
+ try {
69
+ await handler(approval);
70
+ results.push({ channel, success: true });
71
+ } catch (error) {
72
+ results.push({ channel, success: false, error: error.message });
73
+ }
74
+ } else {
75
+ results.push({ channel, success: false, error: 'Channel not registered' });
76
+ }
77
+ }
78
+
79
+ return results;
80
+ }
81
+
82
+ /**
83
+ * Console notification (built-in)
84
+ */
85
+ async notifyConsole(approval) {
86
+ const status = approval.status === 'pending' ? '⏳ PENDING' :
87
+ approval.status === 'approved' ? '✅ APPROVED' :
88
+ approval.status === 'rejected' ? '❌ REJECTED' : '⏰ EXPIRED';
89
+
90
+ console.log(`
91
+ ╔══════════════════════════════════════════════════════════════╗
92
+ ║ HITL Approval ${status.padEnd(50)}║
93
+ ╠══════════════════════════════════════════════════════════════╣
94
+ ║ ID: ${approval.id.padEnd(50)}║
95
+ ║ Action: ${approval.action.padEnd(50)}║
96
+ ║ Requester: ${approval.requester.padEnd(50)}║
97
+ ║ Status: ${status.padEnd(50)}║
98
+ ║ Created: ${new Date(approval.createdAt).toLocaleString().padEnd(50)}║
99
+ ${approval.expiresAt ? `║ Expires: ${new Date(approval.expiresAt).toLocaleString().padEnd(50)}║` : ''}
100
+ ${approval.context ? `║ Context: ${JSON.stringify(approval.context).substring(0, 48).padEnd(50)}║` : ''}
101
+ ╚══════════════════════════════════════════════════════════════╝
102
+ `);
103
+ }
104
+
105
+ /**
106
+ * Webhook notification (built-in)
107
+ */
108
+ async notifyWebhook(webhookUrl, approval) {
109
+ const payload = {
110
+ type: 'hitl_approval',
111
+ approval: {
112
+ id: approval.id,
113
+ action: approval.action,
114
+ context: approval.context,
115
+ requester: approval.requester,
116
+ status: approval.status,
117
+ createdAt: approval.createdAt,
118
+ expiresAt: approval.expiresAt,
119
+ },
120
+ timestamp: new Date().toISOString(),
121
+ };
122
+
123
+ const response = await fetch(webhookUrl, {
124
+ method: 'POST',
125
+ headers: { 'Content-Type': 'application/json' },
126
+ body: JSON.stringify(payload),
127
+ });
128
+
129
+ if (!response.ok) {
130
+ throw new Error(`Webhook failed: ${response.status} ${response.statusText}`);
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Email notification (built-in - requires email service)
136
+ */
137
+ async notifyEmail(emailConfig, approval) {
138
+ // This is a placeholder - integrate with actual email service
139
+ // Example: SendGrid, Mailgun, AWS SES, etc.
140
+ console.log(`[EMAIL] Would send approval notification to ${emailConfig.to}`);
141
+ console.log(`Subject: [LSJI] Approval ${approval.status}: ${approval.action}`);
142
+ console.log(`Body: Approval ${approval.id} requires your attention`);
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Create notifier from config
148
+ */
149
+ export function createNotifier(config = {}) {
150
+ return new Notifier(config);
151
+ }
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Approval Store
3
+ *
4
+ * Persistent storage for approval requests and decisions.
5
+ * Uses the existing storage abstraction.
6
+ */
7
+
8
+ import { createStorage } from '../../index.js';
9
+
10
+ /**
11
+ * Approval request record
12
+ * @typedef {Object} ApprovalRecord
13
+ * @property {string} id - Unique approval ID
14
+ * @property {string} action - Action requiring approval
15
+ * @property {Object} context - Context data for the action
16
+ * @property {string} requester - Who requested the approval
17
+ * @property {string} status - 'pending' | 'approved' | 'rejected' | 'expired'
18
+ * @property {Date} createdAt - When request was created
19
+ * @property {Date} [expiresAt] - When request expires
20
+ * @property {Date} [decidedAt] - When decision was made
21
+ * @property {string} [decider] - Who made the decision
22
+ * @property {string} [reason] - Reason for approval/rejection
23
+ * @property {Object} [metadata] - Additional metadata
24
+ */
25
+
26
+ /**
27
+ * Approval Store - Manages approval requests in storage
28
+ */
29
+ export class ApprovalStore {
30
+ constructor(storage) {
31
+ this.storage = storage;
32
+ this.initialized = false;
33
+ }
34
+
35
+ /**
36
+ * Check if storage is SQL-based (has db with all method)
37
+ */
38
+ isSqlStorage() {
39
+ return this.storage.db && typeof this.storage.db.all === 'function';
40
+ }
41
+
42
+ /**
43
+ * Initialize the approval table
44
+ */
45
+ async initialize() {
46
+ if (this.initialized) return;
47
+
48
+ // Create approvals table if using SQL storage
49
+ if (this.isSqlStorage()) {
50
+ await this.storage.db.exec(`
51
+ CREATE TABLE IF NOT EXISTS approvals (
52
+ id TEXT PRIMARY KEY,
53
+ action TEXT NOT NULL,
54
+ context TEXT NOT NULL,
55
+ requester TEXT NOT NULL,
56
+ status TEXT NOT NULL DEFAULT 'pending',
57
+ created_at TEXT NOT NULL,
58
+ expires_at TEXT,
59
+ decided_at TEXT,
60
+ decider TEXT,
61
+ reason TEXT,
62
+ metadata TEXT
63
+ )
64
+ `);
65
+
66
+ // Create index for pending approvals
67
+ await this.storage.db.exec(`
68
+ CREATE INDEX IF NOT EXISTS idx_approvals_status ON approvals(status)
69
+ `);
70
+
71
+ await this.storage.db.exec(`
72
+ CREATE INDEX IF NOT EXISTS idx_approvals_created ON approvals(created_at)
73
+ `);
74
+ }
75
+
76
+ this.initialized = true;
77
+ }
78
+
79
+ /**
80
+ * Create a new approval request
81
+ */
82
+ async createApproval(approval) {
83
+ await this.initialize();
84
+
85
+ const record = {
86
+ id: approval.id,
87
+ action: approval.action,
88
+ context: JSON.stringify(approval.context),
89
+ requester: approval.requester,
90
+ status: 'pending',
91
+ createdAt: approval.createdAt || new Date().toISOString(),
92
+ expiresAt: approval.expiresAt?.toISOString() || null,
93
+ decidedAt: null,
94
+ decider: null,
95
+ reason: null,
96
+ metadata: approval.metadata ? JSON.stringify(approval.metadata) : null,
97
+ };
98
+
99
+ if (this.isSqlStorage()) {
100
+ await this.storage.db.run(
101
+ `INSERT INTO approvals (id, action, context, requester, status, created_at, expires_at, decided_at, decider, reason, metadata)
102
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
103
+ [record.id, record.action, record.context, record.requester, record.status,
104
+ record.createdAt, record.expiresAt, record.decidedAt, record.decider, record.reason, record.metadata]
105
+ );
106
+ } else {
107
+ // Fallback for memory storage
108
+ if (!this.memoryApprovals) this.memoryApprovals = new Map();
109
+ this.memoryApprovals.set(record.id, record);
110
+ }
111
+
112
+ return record;
113
+ }
114
+
115
+ /**
116
+ * Get approval by ID
117
+ */
118
+ async getApproval(id) {
119
+ await this.initialize();
120
+
121
+ if (this.isSqlStorage()) {
122
+ const row = await this.storage.db.get('SELECT * FROM approvals WHERE id = ?', [id]);
123
+ if (!row) return null;
124
+ return this.rowToRecord(row);
125
+ } else {
126
+ return this.memoryApprovals?.get(id) || null;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Get pending approvals
132
+ */
133
+ async getPendingApprovals(limit = 100) {
134
+ await this.initialize();
135
+
136
+ if (this.isSqlStorage()) {
137
+ const rows = await this.storage.db.all(
138
+ 'SELECT * FROM approvals WHERE status = ? ORDER BY created_at DESC LIMIT ?',
139
+ ['pending', limit]
140
+ );
141
+ return rows.map(r => this.rowToRecord(r));
142
+ } else {
143
+ const approvals = [];
144
+ for (const record of this.memoryApprovals?.values() || []) {
145
+ if (record.status === 'pending') {
146
+ approvals.push(record);
147
+ }
148
+ }
149
+ return approvals.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)).slice(0, limit);
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Get approvals by requester
155
+ */
156
+ async getApprovalsByRequester(requester, limit = 100) {
157
+ await this.initialize();
158
+
159
+ if (this.isSqlStorage()) {
160
+ const rows = await this.storage.db.all(
161
+ 'SELECT * FROM approvals WHERE requester = ? ORDER BY created_at DESC LIMIT ?',
162
+ [requester, limit]
163
+ );
164
+ return rows.map(r => this.rowToRecord(r));
165
+ } else {
166
+ const approvals = [];
167
+ for (const record of this.memoryApprovals?.values() || []) {
168
+ if (record.requester === requester) {
169
+ approvals.push(record);
170
+ }
171
+ }
172
+ return approvals.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)).slice(0, limit);
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Update approval status (approve/reject)
178
+ */
179
+ async decideApproval(id, { status, decider, reason }) {
180
+ await this.initialize();
181
+
182
+ const validStatuses = ['approved', 'rejected'];
183
+ if (!validStatuses.includes(status)) {
184
+ throw new Error(`Invalid status: ${status}. Must be 'approved' or 'rejected'`);
185
+ }
186
+
187
+ const record = await this.getApproval(id);
188
+ if (!record) {
189
+ throw new Error(`Approval not found: ${id}`);
190
+ }
191
+
192
+ if (record.status !== 'pending') {
193
+ throw new Error(`Approval already decided: ${record.status}`);
194
+ }
195
+
196
+ // Check expiration
197
+ if (record.expiresAt && new Date(record.expiresAt) < new Date()) {
198
+ await this.updateStatus(id, 'expired', null, 'Expired');
199
+ throw new Error('Approval request has expired');
200
+ }
201
+
202
+ const decidedAt = new Date().toISOString();
203
+
204
+ if (this.isSqlStorage()) {
205
+ await this.storage.db.run(
206
+ `UPDATE approvals SET status = ?, decided_at = ?, decider = ?, reason = ? WHERE id = ?`,
207
+ [status, decidedAt, decider, reason, id]
208
+ );
209
+ } else {
210
+ record.status = status;
211
+ record.decidedAt = decidedAt;
212
+ record.decider = decider;
213
+ record.reason = reason;
214
+ this.memoryApprovals.set(id, record);
215
+ }
216
+
217
+ return { ...record, status, decidedAt, decider, reason };
218
+ }
219
+
220
+ /**
221
+ * Approve a request
222
+ */
223
+ async approve(id, { decider, reason } = {}) {
224
+ return this.decideApproval(id, { status: 'approved', decider, reason });
225
+ }
226
+
227
+ /**
228
+ * Reject a request
229
+ */
230
+ async reject(id, { decider, reason } = {}) {
231
+ return this.decideApproval(id, { status: 'rejected', decider, reason });
232
+ }
233
+
234
+ /**
235
+ * Update status (internal)
236
+ */
237
+ async updateStatus(id, status, decider = null, reason = null) {
238
+ await this.initialize();
239
+
240
+ if (this.isSqlStorage()) {
241
+ await this.storage.db.run(
242
+ `UPDATE approvals SET status = ?, decided_at = ?, decider = ?, reason = ? WHERE id = ?`,
243
+ [status, new Date().toISOString(), decider, reason, id]
244
+ );
245
+ } else {
246
+ const record = this.memoryApprovals?.get(id);
247
+ if (record) {
248
+ record.status = status;
249
+ record.decidedAt = new Date().toISOString();
250
+ record.decider = decider;
251
+ record.reason = reason;
252
+ this.memoryApprovals.set(id, record);
253
+ }
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Clean up expired approvals
259
+ */
260
+ async cleanupExpired() {
261
+ await this.initialize();
262
+
263
+ const now = new Date().toISOString();
264
+
265
+ if (this.isSqlStorage()) {
266
+ await this.storage.db.run(
267
+ `UPDATE approvals SET status = 'expired' WHERE status = 'pending' AND expires_at < ?`,
268
+ [now]
269
+ );
270
+ } else {
271
+ for (const record of this.memoryApprovals?.values() || []) {
272
+ if (record.status === 'pending' && record.expiresAt && new Date(record.expiresAt) < new Date()) {
273
+ record.status = 'expired';
274
+ this.memoryApprovals.set(record.id, record);
275
+ }
276
+ }
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Convert database row to record
282
+ */
283
+ rowToRecord(row) {
284
+ return {
285
+ id: row.id,
286
+ action: row.action,
287
+ context: JSON.parse(row.context),
288
+ requester: row.requester,
289
+ status: row.status,
290
+ createdAt: row.created_at,
291
+ expiresAt: row.expires_at,
292
+ decidedAt: row.decided_at,
293
+ decider: row.decider,
294
+ reason: row.reason,
295
+ metadata: row.metadata ? JSON.parse(row.metadata) : null,
296
+ };
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Create approval store from storage config
302
+ */
303
+ export async function createApprovalStore(storageConfig = {}) {
304
+ const storage = await createStorage(
305
+ storageConfig.type || 'sqlite',
306
+ storageConfig.options || {}
307
+ );
308
+ const store = new ApprovalStore(storage);
309
+ await store.initialize();
310
+ return store;
311
+ }