codetac 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/LICENSE +21 -0
- package/package.json +60 -0
- package/readme.md +172 -0
- package/src/action-view.mjs +104 -0
- package/src/ai.mjs +504 -0
- package/src/boundaries.mjs +483 -0
- package/src/browser/bar.js +593 -0
- package/src/cli.mjs +556 -0
- package/src/detect.mjs +198 -0
- package/src/diagnose.mjs +121 -0
- package/src/digest.mjs +317 -0
- package/src/home.mjs +15 -0
- package/src/origins.mjs +182 -0
- package/src/page.mjs +220 -0
- package/src/panel.mjs +800 -0
- package/src/recording.mjs +124 -0
- package/src/redact.mjs +35 -0
- package/src/register.mjs +94 -0
- package/src/runtime.mjs +353 -0
- package/src/sentences.mjs +209 -0
- package/src/store.mjs +376 -0
- package/src/transform.mjs +255 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Frases fixas, geradas só dos factos gravados (modo sem IA e base de
|
|
2
|
+
// comparação para as frases da IA). Idioma: CODETAC_LANG (pt-PT por omissão).
|
|
3
|
+
// Nenhuma frase interpreta o nome de uma função: diz o que se observou.
|
|
4
|
+
|
|
5
|
+
export const LANGUAGES = ['pt-PT', 'en'];
|
|
6
|
+
export function language(value = process.env.CODETAC_LANG) {
|
|
7
|
+
const text = String(value ?? '').toLowerCase();
|
|
8
|
+
return text.startsWith('en') ? 'en' : 'pt-PT';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const list = (items, and) => items.length <= 1 ? items.join('') : `${items.slice(0, -1).join(', ')} ${and} ${items.at(-1)}`;
|
|
12
|
+
const names = (items, and, max = 4) => items.length > max ? `${items.slice(0, max).join(', ')} (+${items.length - max})` : list(items, and);
|
|
13
|
+
|
|
14
|
+
function duration(ms, lang) {
|
|
15
|
+
if (ms == null) return '';
|
|
16
|
+
const text = ms < 1 ? ms.toFixed(2) + ' ms' : ms < 1000 ? ms.toFixed(1) + ' ms' : (ms / 1000).toFixed(2) + ' s';
|
|
17
|
+
return lang === 'en' ? text : text.replace('.', ',');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const PT = {
|
|
21
|
+
and: 'e',
|
|
22
|
+
rows: n => `${n} ${n === 1 ? 'linha' : 'linhas'}`,
|
|
23
|
+
read: (tables, rows) => `Lê ${tables || 'a base de dados'}${rows != null ? ` (${PT.rows(rows)})` : ''}`,
|
|
24
|
+
insert: (tables, n) => `Acrescenta ${n == null ? 'linhas' : PT.rows(n)} a ${tables || 'uma tabela'}`,
|
|
25
|
+
update: (tables, n) => n === 0 ? `Tenta alterar ${tables || 'uma tabela'}; nenhuma linha alterada` : `Altera ${n == null ? 'linhas' : PT.rows(n)} em ${tables || 'uma tabela'}`,
|
|
26
|
+
delete: (tables, n) => n === 0 ? `Tenta apagar em ${tables || 'uma tabela'}; nenhuma linha apagada` : `Apaga ${n == null ? 'linhas' : PT.rows(n)} em ${tables || 'uma tabela'}`,
|
|
27
|
+
create: (tables, ifNot) => `Cria a tabela ${tables || ''}${ifNot ? ' se ainda não existir' : ''}`.trim(),
|
|
28
|
+
alter: tables => `Altera a estrutura de ${tables || 'uma tabela'}`,
|
|
29
|
+
drop: tables => `Remove ${tables || 'uma tabela'}`,
|
|
30
|
+
otherDb: (op, tables) => `Comando ${op} na base de dados${tables ? ` (${tables})` : ''}`,
|
|
31
|
+
failed: 'falhou',
|
|
32
|
+
http: (method, host, path, status) => `Chama ${host}: ${method} ${path}${status != null ? ` (estado ${status})` : ''}`,
|
|
33
|
+
ai: (provider, model, usage) => `Pede uma resposta a ${provider}${model ? ` (${model})` : ''}${usage ? ` · ${usage.input ?? '?'} + ${usage.output ?? '?'} tokens` : ''}`,
|
|
34
|
+
email: (to, subject) => `Envia um email${to.length ? ` para ${to.join(', ')}` : ''}${subject ? ` «${subject}»` : ''}`,
|
|
35
|
+
message: (to, provider) => `Envia uma mensagem por ${provider}${to.length ? ` para ${to.join(', ')}` : ''}`,
|
|
36
|
+
payment: (provider, operation, mode) => `Pagamento em ${provider}: ${operation} (modo ${mode})`,
|
|
37
|
+
fileRead: (where, bytes) => `Lê o ficheiro ${where}${bytes ? ` (${bytes} bytes)` : ''}`,
|
|
38
|
+
fileWrite: (op, where, bytes) => `${op[0].toUpperCase() + op.slice(1)} de ficheiro: ${where}${bytes ? ` (${bytes} bytes)` : ''}`,
|
|
39
|
+
auth: (provider, operation) => `Autenticação em ${provider}${operation ? `: ${operation}` : ''}`,
|
|
40
|
+
// Funções
|
|
41
|
+
calls: items => `chama ${items}`,
|
|
42
|
+
noBoundary: 'Trabalho interno: nenhuma fronteira observada',
|
|
43
|
+
error: 'terminou com erro',
|
|
44
|
+
unfinished: 'não terminou durante a gravação',
|
|
45
|
+
dbSetupPart: n => `executa ${n} comandos de estrutura da base de dados`,
|
|
46
|
+
readPart: tables => `lê ${tables}`,
|
|
47
|
+
writePart: (verb, tables) => `${verb} ${tables}`,
|
|
48
|
+
writeVerbs: { INSERT: 'acrescenta a', UPDATE: 'altera', DELETE: 'apaga em', REPLACE: 'substitui em', UPSERT: 'acrescenta ou altera', MERGE: 'junta em' },
|
|
49
|
+
idleWritePart: tables => `tenta escrever em ${tables} sem alterar linhas`,
|
|
50
|
+
httpPart: hosts => `chama ${hosts}`,
|
|
51
|
+
aiPart: providers => `pede respostas a ${providers}`,
|
|
52
|
+
emailPart: n => n === 1 ? 'envia um email' : `envia ${n} emails`,
|
|
53
|
+
paymentPart: providers => `faz pagamentos em ${providers}`,
|
|
54
|
+
filePart: ops => `ficheiros: ${ops}`,
|
|
55
|
+
authPart: providers => `autenticação em ${providers}`,
|
|
56
|
+
// Grupos
|
|
57
|
+
dbSetup: (n, counts, tables, failed, changed, parentOk) => `Preparação da base de dados: ${n} comandos (${counts}) em ${tables} ${tables === 1 ? 'tabela' : 'tabelas'}` +
|
|
58
|
+
(failed ? ` · ${failed} falharam${parentOk ? ' sem interromper a função' : ''}` : '') +
|
|
59
|
+
(changed ? ` · inclui escritas que alteraram ${PT.rows(changed)}` : ''),
|
|
60
|
+
repeated: (sentence, n) => `${sentence} — ${n} vezes seguidas`,
|
|
61
|
+
block: (times, size, what) => `O mesmo conjunto de ${size} passos repetido ${times} vezes${what ? `: ${what}` : ''}`,
|
|
62
|
+
helpers: (n, fns) => `${n} funções auxiliares sem fronteiras: ${fns}`,
|
|
63
|
+
generated: n => `${n} funções de código gerado pelo bundler`,
|
|
64
|
+
devTools: n => `${n} ${n === 1 ? 'pedido' : 'pedidos'} da ferramenta de desenvolvimento (recompilação, hot reload)`,
|
|
65
|
+
// Efeitos
|
|
66
|
+
effects: {
|
|
67
|
+
added: (n, table) => `${n == null ? 'Linhas acrescentadas' : `${PT.rows(n)} ${n === 1 ? 'acrescentada' : 'acrescentadas'}`} em ${table}`,
|
|
68
|
+
changed: (n, table) => `${n == null ? 'Linhas alteradas' : `${PT.rows(n)} ${n === 1 ? 'alterada' : 'alteradas'}`} em ${table}`,
|
|
69
|
+
deleted: (n, table) => `${n == null ? 'Linhas apagadas' : `${PT.rows(n)} ${n === 1 ? 'apagada' : 'apagadas'}`} em ${table}`,
|
|
70
|
+
otherWrite: (op, table) => `${op} em ${table}`,
|
|
71
|
+
noChange: (op, table, n) => `${op} em ${table} sem linhas alteradas${n > 1 ? ` (${n} vezes)` : ''}`,
|
|
72
|
+
structure: (ok, failed) => `${ok + failed} comandos de estrutura da base de dados (${ok} sem erro${failed ? `, ${failed} falharam` : ''}); a gravação não indica se alteraram a base`,
|
|
73
|
+
email: (to, subject, failed) => `${failed ? 'Tentativa de email falhada' : 'Email enviado'}${to.length ? ` para ${to.join(', ')}` : ''}${subject ? ` «${subject}»` : ''}`,
|
|
74
|
+
message: (provider, failed) => `${failed ? 'Tentativa de mensagem falhada' : 'Mensagem enviada'} por ${provider}`,
|
|
75
|
+
payment: (provider, operation, mode) => `Pagamento em ${provider}: ${operation} (modo ${mode})`,
|
|
76
|
+
file: (op, where) => `Ficheiro: ${op} em ${where}`,
|
|
77
|
+
cookieSet: names => `Cookies guardados no browser: ${names}`,
|
|
78
|
+
cookieCleared: names => `Cookies apagados no browser: ${names}`,
|
|
79
|
+
ai: (provider, model, usage, cost) => `Chamada de IA a ${provider}${model ? ` (${model})` : ''}${usage ? ` · ${usage.input ?? '?'} + ${usage.output ?? '?'} tokens` : ''}${cost != null ? ` · ~US$ ${cost.toFixed(4)}` : ''}`,
|
|
80
|
+
external: (method, host, n) => `Chamada externa ${method} a ${host}${n > 1 ? ` (${n} vezes)` : ''}`,
|
|
81
|
+
none: 'Nenhum efeito permanente observado.',
|
|
82
|
+
unseen: 'Não observado: armazenamento local do browser e efeitos em serviços não reconhecidos.',
|
|
83
|
+
},
|
|
84
|
+
// Ação
|
|
85
|
+
action: {
|
|
86
|
+
click: label => `Clique em ${label}`,
|
|
87
|
+
submit: label => `Envio de ${label}`,
|
|
88
|
+
change: label => `Alteração em ${label}`,
|
|
89
|
+
continuation: 'Continuação após navegação',
|
|
90
|
+
request: (method, path, status) => `${method} ${path}${status != null ? ` (estado ${status})` : ''}`,
|
|
91
|
+
noServer: 'sem pedidos ao servidor',
|
|
92
|
+
screen: 'altera o ecrã',
|
|
93
|
+
noScreen: 'sem alteração visível no ecrã',
|
|
94
|
+
navigates: path => `muda para ${path}`,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const EN = {
|
|
99
|
+
and: 'and',
|
|
100
|
+
rows: n => `${n} ${n === 1 ? 'row' : 'rows'}`,
|
|
101
|
+
read: (tables, rows) => `Reads ${tables || 'the database'}${rows != null ? ` (${EN.rows(rows)})` : ''}`,
|
|
102
|
+
insert: (tables, n) => `Adds ${n == null ? 'rows' : EN.rows(n)} to ${tables || 'a table'}`,
|
|
103
|
+
update: (tables, n) => n === 0 ? `Tries to change ${tables || 'a table'}; no rows changed` : `Changes ${n == null ? 'rows' : EN.rows(n)} in ${tables || 'a table'}`,
|
|
104
|
+
delete: (tables, n) => n === 0 ? `Tries to delete from ${tables || 'a table'}; no rows deleted` : `Deletes ${n == null ? 'rows' : EN.rows(n)} from ${tables || 'a table'}`,
|
|
105
|
+
create: (tables, ifNot) => `Creates table ${tables || ''}${ifNot ? ' if it does not exist' : ''}`.trim(),
|
|
106
|
+
alter: tables => `Changes the structure of ${tables || 'a table'}`,
|
|
107
|
+
drop: tables => `Drops ${tables || 'a table'}`,
|
|
108
|
+
otherDb: (op, tables) => `${op} command on the database${tables ? ` (${tables})` : ''}`,
|
|
109
|
+
failed: 'failed',
|
|
110
|
+
http: (method, host, path, status) => `Calls ${host}: ${method} ${path}${status != null ? ` (status ${status})` : ''}`,
|
|
111
|
+
ai: (provider, model, usage) => `Asks ${provider} for a response${model ? ` (${model})` : ''}${usage ? ` · ${usage.input ?? '?'} + ${usage.output ?? '?'} tokens` : ''}`,
|
|
112
|
+
email: (to, subject) => `Sends an email${to.length ? ` to ${to.join(', ')}` : ''}${subject ? ` “${subject}”` : ''}`,
|
|
113
|
+
message: (to, provider) => `Sends a message via ${provider}${to.length ? ` to ${to.join(', ')}` : ''}`,
|
|
114
|
+
payment: (provider, operation, mode) => `Payment on ${provider}: ${operation} (${mode} mode)`,
|
|
115
|
+
fileRead: (where, bytes) => `Reads file ${where}${bytes ? ` (${bytes} bytes)` : ''}`,
|
|
116
|
+
fileWrite: (op, where, bytes) => `File ${op}: ${where}${bytes ? ` (${bytes} bytes)` : ''}`,
|
|
117
|
+
auth: (provider, operation) => `Authentication with ${provider}${operation ? `: ${operation}` : ''}`,
|
|
118
|
+
calls: items => `calls ${items}`,
|
|
119
|
+
noBoundary: 'Internal work: no boundary observed',
|
|
120
|
+
error: 'ended with an error',
|
|
121
|
+
unfinished: 'did not finish during the recording',
|
|
122
|
+
dbSetupPart: n => `runs ${n} database structure commands`,
|
|
123
|
+
readPart: tables => `reads ${tables}`,
|
|
124
|
+
writePart: (verb, tables) => `${verb} ${tables}`,
|
|
125
|
+
writeVerbs: { INSERT: 'adds to', UPDATE: 'changes', DELETE: 'deletes from', REPLACE: 'replaces in', UPSERT: 'adds or changes', MERGE: 'merges into' },
|
|
126
|
+
idleWritePart: tables => `tries to write to ${tables} without changing rows`,
|
|
127
|
+
httpPart: hosts => `calls ${hosts}`,
|
|
128
|
+
aiPart: providers => `asks ${providers} for responses`,
|
|
129
|
+
emailPart: n => n === 1 ? 'sends an email' : `sends ${n} emails`,
|
|
130
|
+
paymentPart: providers => `makes payments on ${providers}`,
|
|
131
|
+
filePart: ops => `files: ${ops}`,
|
|
132
|
+
authPart: providers => `authentication with ${providers}`,
|
|
133
|
+
dbSetup: (n, counts, tables, failed, changed, parentOk) => `Database setup: ${n} commands (${counts}) on ${tables} ${tables === 1 ? 'table' : 'tables'}` +
|
|
134
|
+
(failed ? ` · ${failed} failed${parentOk ? ' without stopping the function' : ''}` : '') +
|
|
135
|
+
(changed ? ` · includes writes that changed ${EN.rows(changed)}` : ''),
|
|
136
|
+
repeated: (sentence, n) => `${sentence} — ${n} times in a row`,
|
|
137
|
+
block: (times, size, what) => `The same ${size} steps repeated ${times} times${what ? `: ${what}` : ''}`,
|
|
138
|
+
helpers: (n, fns) => `${n} helper functions with no boundaries: ${fns}`,
|
|
139
|
+
generated: n => `${n} functions of bundler-generated code`,
|
|
140
|
+
devTools: n => `${n} development tool ${n === 1 ? 'request' : 'requests'} (recompilation, hot reload)`,
|
|
141
|
+
effects: {
|
|
142
|
+
added: (n, table) => `${n == null ? 'Rows' : EN.rows(n)} added to ${table}`,
|
|
143
|
+
changed: (n, table) => `${n == null ? 'Rows' : EN.rows(n)} changed in ${table}`,
|
|
144
|
+
deleted: (n, table) => `${n == null ? 'Rows' : EN.rows(n)} deleted from ${table}`,
|
|
145
|
+
otherWrite: (op, table) => `${op} on ${table}`,
|
|
146
|
+
noChange: (op, table, n) => `${op} on ${table} changed no rows${n > 1 ? ` (${n} times)` : ''}`,
|
|
147
|
+
structure: (ok, failed) => `${ok + failed} database structure commands (${ok} without error${failed ? `, ${failed} failed` : ''}); the recording does not show whether they changed the database`,
|
|
148
|
+
email: (to, subject, failed) => `${failed ? 'Failed email attempt' : 'Email sent'}${to.length ? ` to ${to.join(', ')}` : ''}${subject ? ` “${subject}”` : ''}`,
|
|
149
|
+
message: (provider, failed) => `${failed ? 'Failed message attempt' : 'Message sent'} via ${provider}`,
|
|
150
|
+
payment: (provider, operation, mode) => `Payment on ${provider}: ${operation} (${mode} mode)`,
|
|
151
|
+
file: (op, where) => `File: ${op} on ${where}`,
|
|
152
|
+
cookieSet: names => `Cookies stored in the browser: ${names}`,
|
|
153
|
+
cookieCleared: names => `Cookies deleted in the browser: ${names}`,
|
|
154
|
+
ai: (provider, model, usage, cost) => `AI call to ${provider}${model ? ` (${model})` : ''}${usage ? ` · ${usage.input ?? '?'} + ${usage.output ?? '?'} tokens` : ''}${cost != null ? ` · ~US$ ${cost.toFixed(4)}` : ''}`,
|
|
155
|
+
external: (method, host, n) => `External ${method} call to ${host}${n > 1 ? ` (${n} times)` : ''}`,
|
|
156
|
+
none: 'No lasting effect observed.',
|
|
157
|
+
unseen: 'Not observed: browser local storage and effects on unrecognised services.',
|
|
158
|
+
},
|
|
159
|
+
action: {
|
|
160
|
+
click: label => `Click on ${label}`,
|
|
161
|
+
submit: label => `Submit of ${label}`,
|
|
162
|
+
change: label => `Change in ${label}`,
|
|
163
|
+
continuation: 'Continuation after navigation',
|
|
164
|
+
request: (method, path, status) => `${method} ${path}${status != null ? ` (status ${status})` : ''}`,
|
|
165
|
+
noServer: 'no server requests',
|
|
166
|
+
screen: 'changes the screen',
|
|
167
|
+
noScreen: 'no visible change on screen',
|
|
168
|
+
navigates: path => `goes to ${path}`,
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export function texts(lang) { return lang === 'en' ? EN : PT; }
|
|
173
|
+
export { duration, names, list };
|
|
174
|
+
|
|
175
|
+
// The sentence of one boundary, from its recorded facts.
|
|
176
|
+
export function boundarySentence(step, lang) {
|
|
177
|
+
const t = texts(lang);
|
|
178
|
+
const r = step.result ?? {};
|
|
179
|
+
const failed = step.error || r.error;
|
|
180
|
+
const tables = (step.tables ?? []).join(', ');
|
|
181
|
+
let text;
|
|
182
|
+
switch (step.kind) {
|
|
183
|
+
case 'base-de-dados': {
|
|
184
|
+
const op = step.operation;
|
|
185
|
+
if (op === 'SELECT' || op === 'WITH' || op === 'PRAGMA') text = t.read(tables, r.rows);
|
|
186
|
+
else if (op === 'INSERT') text = t.insert(tables, r.affectedRows);
|
|
187
|
+
else if (op === 'UPDATE') text = t.update(tables, r.affectedRows);
|
|
188
|
+
else if (op === 'DELETE') text = t.delete(tables, r.affectedRows);
|
|
189
|
+
else if (op === 'CREATE') text = t.create(tables, /\bif\s+not\s+exists\b/i.test(step.sql ?? ''));
|
|
190
|
+
else if (op === 'ALTER') text = t.alter(tables);
|
|
191
|
+
else if (op === 'DROP') text = t.drop(tables);
|
|
192
|
+
else text = t.otherDb(op, tables);
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
case 'http': text = t.http(step.method ?? '', step.host ?? '', step.path ?? '', r.status); break;
|
|
196
|
+
case 'ia': text = t.ai(step.provider, r.model ?? step.model, r.usage); break;
|
|
197
|
+
case 'email': text = t.email(step.to ?? [], step.subject); break;
|
|
198
|
+
case 'mensagem': text = t.message(step.to ?? [], step.provider ?? step.library); break;
|
|
199
|
+
case 'pagamento': text = t.payment(step.provider, step.operation, step.mode); break;
|
|
200
|
+
case 'ficheiros': {
|
|
201
|
+
const where = step.bucket ? `${step.provider} ${step.bucket}` : step.path ?? step.provider;
|
|
202
|
+
text = step.operation === 'leitura' || step.operation === 'verificação' ? t.fileRead(where, r.bytes) : t.fileWrite(step.operation ?? '?', where, step.bytes);
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
case 'autenticação': text = t.auth(step.provider ?? step.library, step.operation); break;
|
|
206
|
+
default: text = `${step.kind}${step.operation ? `: ${step.operation}` : ''}`;
|
|
207
|
+
}
|
|
208
|
+
return failed ? `${text} — ${t.failed}` : text;
|
|
209
|
+
}
|
package/src/store.mjs
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
// Local store: ingests the JSONL recordings into an SQLite database inside
|
|
2
|
+
// CodeTAC's own folder and builds the dossier of each HTTP request.
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, statSync } from 'node:fs';
|
|
5
|
+
import { join, relative, resolve, sep } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const WRITES = new Set(['INSERT', 'UPDATE', 'DELETE', 'UPSERT', 'REPLACE', 'MERGE']);
|
|
8
|
+
const STRUCTURE = new Set(['CREATE', 'DROP', 'ALTER', 'TRUNCATE']);
|
|
9
|
+
const KINDS = { a: 'ligação', button: 'botão', input: 'campo', select: 'lista', textarea: 'caixa de texto', label: 'etiqueta', summary: 'secção', form: 'formulário' };
|
|
10
|
+
|
|
11
|
+
// "botão «Entrar»": the element as the person saw it.
|
|
12
|
+
export function actionLabel(trigger) {
|
|
13
|
+
const element = trigger?.element;
|
|
14
|
+
if (!element) return 'continuação após navegação';
|
|
15
|
+
const button = element.role === 'button' || (element.tag === 'input' && /^(submit|button|reset)$/.test(element.type ?? ''));
|
|
16
|
+
const kind = button ? 'botão' : KINDS[element.tag] ?? element.role ?? element.tag ?? 'elemento';
|
|
17
|
+
const text = element.text || element.label || element.name || element.id;
|
|
18
|
+
const suffix = trigger.event === 'change' ? (element.type === 'file' ? ' (ficheiro escolhido)' : ' (alteração)') : '';
|
|
19
|
+
return `${kind}${text ? ` «${text}»` : ''}${suffix}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function openStore(folder, { keep = 500, keepRuns = 20 } = {}) {
|
|
23
|
+
const directory = resolve(folder);
|
|
24
|
+
// A new installation has no data folder yet (~/.codetac).
|
|
25
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
26
|
+
const db = new DatabaseSync(join(directory, 'codetac.db'));
|
|
27
|
+
db.exec(`
|
|
28
|
+
pragma journal_mode = wal;
|
|
29
|
+
create table if not exists ingested (file text primary key, offset integer not null);
|
|
30
|
+
create table if not exists runs (run text primary key, root text, node text, level text);
|
|
31
|
+
create table if not exists requests (request_id text primary key, run text, process text, method text, path text,
|
|
32
|
+
query_keys text, at integer, status integer, duration_ns integer, aborted integer, ended integer not null default 0);
|
|
33
|
+
create table if not exists events (request_id text not null, process text, sequence integer, type text, id text, data text);
|
|
34
|
+
create index if not exists events_request on events(request_id);
|
|
35
|
+
create table if not exists limitations (run text, file text, reason text, line integer, detail text,
|
|
36
|
+
unique(run, file, reason, line));
|
|
37
|
+
create table if not exists actions (action_id text not null, segment integer not null, run text, at integer, data text,
|
|
38
|
+
primary key(action_id, segment));
|
|
39
|
+
create table if not exists origins (key text primary key, data text not null);
|
|
40
|
+
create table if not exists purposes (key text primary key, data text not null, at integer);
|
|
41
|
+
`);
|
|
42
|
+
// Recordings made before the browser link have no action columns.
|
|
43
|
+
const columns = new Set(db.prepare('pragma table_info(requests)').all().map(column => column.name));
|
|
44
|
+
if (!columns.has('action')) db.exec('alter table requests add column action text; alter table requests add column action_request integer;');
|
|
45
|
+
if (!columns.has('cookies')) db.exec('alter table requests add column cookies text;');
|
|
46
|
+
// Phase 5: the request that loaded a page (it received the bar).
|
|
47
|
+
if (!columns.has('document')) db.exec('alter table requests add column document integer;');
|
|
48
|
+
db.exec('create index if not exists requests_action on requests(action)');
|
|
49
|
+
// Phase 5: why a recording is in minimal mode.
|
|
50
|
+
if (!db.prepare('pragma table_info(runs)').all().some(column => column.name === 'reason')) db.exec('alter table runs add column reason text;');
|
|
51
|
+
// Offsets used to be kept by the path as given, so opening the store from
|
|
52
|
+
// another working directory read every recording again and duplicated its
|
|
53
|
+
// events. They are now kept relative to the store's folder.
|
|
54
|
+
if (db.prepare("select 1 from sqlite_master where type = 'table' and name = 'files'").get()) {
|
|
55
|
+
db.exec('begin');
|
|
56
|
+
const move = db.prepare('insert into ingested(file, offset) values (?, ?) on conflict(file) do update set offset = max(offset, excluded.offset)');
|
|
57
|
+
for (const row of db.prepare('select path, offset from files').all()) {
|
|
58
|
+
if (row.path.startsWith(directory + sep)) move.run(relative(directory, row.path), row.offset);
|
|
59
|
+
}
|
|
60
|
+
db.exec(`drop table files;
|
|
61
|
+
delete from events where rowid not in (select min(rowid) from events group by request_id, process, sequence, type);
|
|
62
|
+
commit;`);
|
|
63
|
+
}
|
|
64
|
+
db.exec('create unique index if not exists events_unique on events(request_id, process, sequence, type)');
|
|
65
|
+
const statements = {
|
|
66
|
+
offset: db.prepare('select offset from ingested where file = ?'),
|
|
67
|
+
setOffset: db.prepare('insert into ingested(file, offset) values (?, ?) on conflict(file) do update set offset = excluded.offset'),
|
|
68
|
+
run: db.prepare('insert into runs(run, root, node, level, reason) values (?, ?, ?, ?, ?) on conflict(run) do update set root = excluded.root, node = excluded.node, level = excluded.level, reason = excluded.reason'),
|
|
69
|
+
request: db.prepare('insert or ignore into requests(request_id, run, process, method, path, query_keys, at, action, action_request) values (?, ?, ?, ?, ?, ?, ?, ?, ?)'),
|
|
70
|
+
action: db.prepare('insert or replace into actions(action_id, segment, run, at, data) values (?, ?, ?, ?, ?)'),
|
|
71
|
+
requestEnd: db.prepare('update requests set status = ?, duration_ns = ?, aborted = ?, cookies = ?, ended = 1 where request_id = ?'),
|
|
72
|
+
event: db.prepare('insert or ignore into events(request_id, process, sequence, type, id, data) values (?, ?, ?, ?, ?, ?)'),
|
|
73
|
+
limitation: db.prepare('insert or ignore into limitations(run, file, reason, line, detail) values (?, ?, ?, ?, ?)'),
|
|
74
|
+
document: db.prepare('update requests set document = 1 where request_id = ?'),
|
|
75
|
+
};
|
|
76
|
+
// A page event and the request that follows it with the same path, by process.
|
|
77
|
+
const pages = new Map();
|
|
78
|
+
const documents = new Set();
|
|
79
|
+
|
|
80
|
+
// Reads only the bytes appended since the previous ingestion.
|
|
81
|
+
function ingestFile(run, path) {
|
|
82
|
+
const size = statSync(path).size;
|
|
83
|
+
const key = relative(directory, path);
|
|
84
|
+
const from = statements.offset.get(key)?.offset ?? 0;
|
|
85
|
+
if (size <= from) return;
|
|
86
|
+
const fd = openSync(path, 'r');
|
|
87
|
+
let text;
|
|
88
|
+
try {
|
|
89
|
+
const buffer = Buffer.alloc(Math.min(size - from, 64 * 1024 * 1024));
|
|
90
|
+
const read = readSync(fd, buffer, 0, buffer.length, from);
|
|
91
|
+
text = buffer.subarray(0, read).toString('utf8');
|
|
92
|
+
} finally { closeSync(fd); }
|
|
93
|
+
const complete = text.lastIndexOf('\n');
|
|
94
|
+
if (complete < 0) return;
|
|
95
|
+
for (const line of text.slice(0, complete).split('\n')) {
|
|
96
|
+
if (!line) continue;
|
|
97
|
+
let event;
|
|
98
|
+
try { event = JSON.parse(line); } catch { continue; }
|
|
99
|
+
switch (event.type) {
|
|
100
|
+
case 'capture-start': statements.run.run(run, event.root ?? null, event.node ?? null, event.level ?? null, event.reason ?? null); break;
|
|
101
|
+
case 'page': pages.set(event.process, event.path); break;
|
|
102
|
+
case 'request':
|
|
103
|
+
if (pages.get(event.process) === event.path) { pages.delete(event.process); documents.add(event.requestId); }
|
|
104
|
+
statements.request.run(event.requestId, run, event.process, event.method, event.path,
|
|
105
|
+
JSON.stringify(event.queryKeys ?? []), event.at ?? null, event.action ?? null, event.actionRequest ?? null);
|
|
106
|
+
if (documents.has(event.requestId)) statements.document.run(event.requestId);
|
|
107
|
+
break;
|
|
108
|
+
case 'browser-action': statements.action.run(event.actionId, event.segment ?? 1, run, event.startedAt ?? null, line); break;
|
|
109
|
+
case 'request-end': statements.requestEnd.run(event.status ?? null, event.durationNs ?? null, event.aborted ? 1 : 0,
|
|
110
|
+
event.cookies ? JSON.stringify(event.cookies) : null, event.requestId); break;
|
|
111
|
+
case 'limitation': statements.limitation.run(run, event.file ?? '', event.reason ?? '', event.line ?? null, event.detail ?? null); break;
|
|
112
|
+
case 'enter': case 'exit': case 'boundary': case 'boundary-end': case 'detail':
|
|
113
|
+
if (event.requestId) statements.event.run(event.requestId, event.process, event.sequence, event.type, event.id, line);
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
statements.setOffset.run(key, from + Buffer.byteLength(text.slice(0, complete + 1)));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function ingest() {
|
|
121
|
+
if (!existsSync(directory)) return;
|
|
122
|
+
db.exec('begin');
|
|
123
|
+
try {
|
|
124
|
+
for (const run of readdirSync(directory)) {
|
|
125
|
+
const folder = join(directory, run);
|
|
126
|
+
if (run === 'projetos' || /^(test|bundle)-/.test(run) || !statSync(folder).isDirectory()) continue;
|
|
127
|
+
for (const name of readdirSync(folder)) if (name.endsWith('.jsonl')) ingestFile(run, join(folder, name));
|
|
128
|
+
}
|
|
129
|
+
// History: the last `keep` requests of each recording, and the last `keepRuns` recordings.
|
|
130
|
+
const remove = db.prepare('delete from events where request_id = ?');
|
|
131
|
+
const removeRequest = db.prepare('delete from requests where request_id = ?');
|
|
132
|
+
const runs = db.prepare('select run, max(at) as last, count(*) as n from requests group by run order by last desc').all();
|
|
133
|
+
runs.forEach((entry, index) => {
|
|
134
|
+
const excess = index >= keepRuns ? entry.n : entry.n - keep;
|
|
135
|
+
if (excess <= 0) return;
|
|
136
|
+
// Within a recording, files served without anything of the project in
|
|
137
|
+
// them (Vite modules, images…) go first: a single page may load more
|
|
138
|
+
// than a thousand, and the page load itself must stay.
|
|
139
|
+
const order = index >= keepRuns ? 'at asc' : `(action is null and document is null
|
|
140
|
+
and not exists (select 1 from events e where e.request_id = requests.request_id)) desc, at asc`;
|
|
141
|
+
for (const { request_id: id } of db.prepare(`select request_id from requests where run = ? order by ${order} limit ?`).all(entry.run, excess)) {
|
|
142
|
+
remove.run(id);
|
|
143
|
+
removeRequest.run(id);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
// Browser actions follow the same rule, by recording.
|
|
147
|
+
for (const entry of db.prepare('select run, count(distinct action_id) as n from actions group by run').all()) {
|
|
148
|
+
const excess = entry.n - keep;
|
|
149
|
+
if (excess <= 0) continue;
|
|
150
|
+
db.prepare(`delete from actions where action_id in (select action_id from actions where run = ?
|
|
151
|
+
group by action_id order by min(at) asc limit ?)`).run(entry.run, excess);
|
|
152
|
+
}
|
|
153
|
+
db.exec('commit');
|
|
154
|
+
} catch (error) {
|
|
155
|
+
db.exec('rollback');
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function listRuns() {
|
|
161
|
+
return db.prepare(`select run, max(last) as last, sum(requests) as requests, sum(actions) as actions from (
|
|
162
|
+
select run, max(at) as last, count(*) as requests, 0 as actions from requests group by run
|
|
163
|
+
union all select run, max(at) as last, 0, count(distinct action_id) from actions group by run)
|
|
164
|
+
group by run order by last desc`).all();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function listActions({ limit = 200, run } = {}) {
|
|
168
|
+
const rows = db.prepare(`select action_id, run, min(at) as at, count(*) as segments from actions
|
|
169
|
+
${run ? 'where run = ?' : ''} group by action_id order by at desc limit ?`).all(...(run ? [run, limit] : [limit]));
|
|
170
|
+
const first = db.prepare('select data from actions where action_id = ? order by segment limit 1');
|
|
171
|
+
const counts = db.prepare(`select count(*) as requests, sum(ended) as ended from requests where action = ?`);
|
|
172
|
+
return rows.map(row => {
|
|
173
|
+
const data = JSON.parse(first.get(row.action_id).data);
|
|
174
|
+
const server = counts.get(row.action_id);
|
|
175
|
+
return { actionId: row.action_id, run: row.run, at: row.at, segments: row.segments, label: actionLabel(data.trigger),
|
|
176
|
+
page: data.page?.path, browserRequests: data.requests?.length ?? 0, serverRequests: server.requests, pending: server.requests > (server.ended ?? 0) };
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// The same action seen by the browser (segments: one per page it crossed)
|
|
181
|
+
// and by the server (requests carrying its id), in one sequence.
|
|
182
|
+
function actionDossier(actionId) {
|
|
183
|
+
const segments = db.prepare('select run, data from actions where action_id = ? order by segment').all(actionId)
|
|
184
|
+
.map(row => ({ run: row.run, ...JSON.parse(row.data) }));
|
|
185
|
+
const serverRows = db.prepare('select * from requests where action = ? order by at, request_id').all(actionId);
|
|
186
|
+
if (!segments.length && !serverRows.length) return null;
|
|
187
|
+
const server = serverRows.map(row => ({ number: row.action_request, dossier: dossier(row.request_id) }));
|
|
188
|
+
const used = new Set();
|
|
189
|
+
const timeline = [];
|
|
190
|
+
const take = number => server.filter(item => item.number === number && !used.has(item.dossier.request.requestId))
|
|
191
|
+
.map(item => { used.add(item.dossier.request.requestId); return item.dossier; });
|
|
192
|
+
segments.forEach((segment, index) => {
|
|
193
|
+
if (index === 0 && segment.trigger) timeline.push({ type: 'trigger', segment: segment.segment, page: segment.page, trigger: segment.trigger });
|
|
194
|
+
if (index > 0 || !segment.trigger) {
|
|
195
|
+
// A new document: the request that loaded it carries the action in a cookie.
|
|
196
|
+
timeline.push({ type: 'document', segment: segment.segment, page: segment.page, server: take(null) });
|
|
197
|
+
}
|
|
198
|
+
const events = [
|
|
199
|
+
...(segment.requests ?? []).map(request => ({ at: request.startMs ?? 0, item: { type: 'request', segment: segment.segment, browser: request, server: take(request.n) } })),
|
|
200
|
+
...(segment.navigations ?? []).filter(item => item.kind !== 'documento')
|
|
201
|
+
.map(navigation => ({ at: navigation.atMs ?? 0, item: { type: 'navigation', segment: segment.segment, ...navigation } })),
|
|
202
|
+
].sort((a, b) => a.at - b.at);
|
|
203
|
+
for (const { item } of events) timeline.push(item);
|
|
204
|
+
timeline.push({ type: 'screen', segment: segment.segment, page: segment.page, screen: segment.screen ?? {}, durationMs: segment.durationMs,
|
|
205
|
+
closedBy: segment.closedBy });
|
|
206
|
+
});
|
|
207
|
+
// Requests the server saw for this action but the browser did not report
|
|
208
|
+
// (the page closed before sending, or a request made on its behalf).
|
|
209
|
+
const rest = server.filter(item => !used.has(item.dossier.request.requestId)).map(item => item.dossier);
|
|
210
|
+
if (rest.length) timeline.push({ type: 'unmatched', server: rest });
|
|
211
|
+
|
|
212
|
+
const all = timeline.flatMap(item => item.server ?? []);
|
|
213
|
+
const combined = [];
|
|
214
|
+
for (const mark of all.flatMap(item => item.marks)) {
|
|
215
|
+
const found = combined.find(entry => entry.key === mark.key);
|
|
216
|
+
if (found) found.count += mark.count; else combined.push({ ...mark });
|
|
217
|
+
}
|
|
218
|
+
for (const request of segments.flatMap(segment => segment.requests ?? []).filter(request => !request.sameOrigin && request.host)) {
|
|
219
|
+
const key = `browser:${request.host}`;
|
|
220
|
+
const found = combined.find(entry => entry.key === key);
|
|
221
|
+
if (found) found.count++; else combined.push({ key, count: 1, label: `chamada do browser a ${request.host}` });
|
|
222
|
+
}
|
|
223
|
+
const runs = [...new Set([...segments.map(segment => segment.run), ...serverRows.map(row => row.run)])];
|
|
224
|
+
const limitations = runs.flatMap(run => db.prepare('select reason, count(*) as count from limitations where run = ? group by reason').all(run));
|
|
225
|
+
const first = segments[0];
|
|
226
|
+
return {
|
|
227
|
+
actionId, run: first?.run ?? serverRows[0]?.run, label: first ? actionLabel(first.trigger) : 'ação sem registo do browser', origin: first?.origin ?? null,
|
|
228
|
+
startedAt: first?.startedAt ?? serverRows[0]?.at ?? null,
|
|
229
|
+
durationMs: segments.reduce((total, segment) => total + (segment.durationMs ?? 0), 0) || null,
|
|
230
|
+
browserSeen: segments.length > 0, pending: all.some(item => !item.request.ended),
|
|
231
|
+
scripts: [...new Set(segments.flatMap(segment => segment.scripts ?? []))],
|
|
232
|
+
root: runs.map(run => root(run)).find(Boolean) ?? null, ...runLevel(runs),
|
|
233
|
+
timeline, marks: combined, limitations,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function listRequests({ limit = 200, run, withoutAction = false } = {}) {
|
|
238
|
+
const filter = withoutAction ? ' and action is null' : '';
|
|
239
|
+
const rows = run
|
|
240
|
+
? db.prepare(`select * from requests where run = ?${filter} order by at desc limit ?`).all(run, limit)
|
|
241
|
+
: db.prepare(`select * from requests where 1${filter} order by at desc limit ?`).all(limit);
|
|
242
|
+
const count = db.prepare("select sum(type = 'enter') as functions, sum(type = 'boundary') as boundaries from events where request_id = ?");
|
|
243
|
+
return rows.map(row => ({ ...shapeRequest(row), ...count.get(row.request_id) }));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function shapeRequest(row) {
|
|
247
|
+
return { requestId: row.request_id, run: row.run, action: row.action ?? null, method: row.method, path: row.path,
|
|
248
|
+
queryKeys: JSON.parse(row.query_keys ?? '[]'), at: row.at, status: row.status,
|
|
249
|
+
durationMs: row.duration_ns == null ? null : row.duration_ns / 1e6, aborted: Boolean(row.aborted), ended: Boolean(row.ended),
|
|
250
|
+
cookies: row.cookies ? JSON.parse(row.cookies) : [] };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function dossier(requestId) {
|
|
254
|
+
const row = db.prepare('select * from requests where request_id = ?').get(requestId);
|
|
255
|
+
if (!row) return null;
|
|
256
|
+
const events = db.prepare('select data from events where request_id = ? order by process, sequence').all(requestId).map(item => JSON.parse(item.data));
|
|
257
|
+
const endings = new Map(events.filter(event => event.type === 'exit' || event.type === 'boundary-end').map(event => [event.id, event]));
|
|
258
|
+
// Values and executed lines, for functions whose detail was asked for.
|
|
259
|
+
const recordedValues = new Map(events.filter(event => event.type === 'detail').map(event => [event.id, event]));
|
|
260
|
+
const starts = events.filter(event => event.type === 'enter' || event.type === 'boundary');
|
|
261
|
+
const ids = new Set(starts.map(event => event.id));
|
|
262
|
+
const depth = new Map();
|
|
263
|
+
const steps = starts.map(event => {
|
|
264
|
+
const level = event.parentId && ids.has(event.parentId) ? (depth.get(event.parentId) ?? 0) + 1 : 0;
|
|
265
|
+
depth.set(event.id, level);
|
|
266
|
+
const end = endings.get(event.id);
|
|
267
|
+
const common = { id: event.id, parentId: event.parentId, depth: level, sequence: event.sequence,
|
|
268
|
+
durationMs: end?.durationNs == null ? null : end.durationNs / 1e6, error: Boolean(end?.error), finished: Boolean(end) };
|
|
269
|
+
if (event.type === 'enter') {
|
|
270
|
+
const detail = recordedValues.get(event.id);
|
|
271
|
+
return { ...common, type: 'function', function: event.function, file: event.file, line: event.line,
|
|
272
|
+
endLine: event.endLine ?? null, mapped: event.mapped, async: event.async,
|
|
273
|
+
...(detail ? { detail: { args: detail.args, lines: detail.lines, ...('returned' in detail ? { returned: detail.returned } : { threw: detail.threw }) } } : {}) };
|
|
274
|
+
}
|
|
275
|
+
const { type, id, parentId, requestId: _r, process, sequence, timeNs, version, ...details } = event;
|
|
276
|
+
const { type: _t, id: _i, requestId: _q, durationNs: _d, error: _e, process: _p, sequence: _s, timeNs: _n, version: _v, ...result } = end ?? {};
|
|
277
|
+
const step = { ...common, type: 'boundary', ...details, result };
|
|
278
|
+
if (step.kind === 'ia') step.costUsd = estimateCost(result.model ?? step.model, result.usage);
|
|
279
|
+
return step;
|
|
280
|
+
});
|
|
281
|
+
const limitations = db.prepare('select reason, count(*) as count from limitations where run = ? group by reason order by count desc').all(row.run);
|
|
282
|
+
const runInfo = db.prepare('select root, level, reason from runs where run = ?').get(row.run) ?? {};
|
|
283
|
+
return { request: shapeRequest(row), level: runInfo.level ?? 'normal', reason: runInfo.reason ?? null, root: runInfo.root ?? null,
|
|
284
|
+
steps, marks: marks(steps), limitations };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Cost is derived, never recorded: prices come from a local table
|
|
288
|
+
// (.codetac/precos.json: { "modelo": { "entrada": USD/1M, "saida": USD/1M } }).
|
|
289
|
+
function estimateCost(model, usage) {
|
|
290
|
+
const path = join(directory, 'precos.json');
|
|
291
|
+
if (!model || !usage || !existsSync(path)) return null;
|
|
292
|
+
try {
|
|
293
|
+
const price = JSON.parse(readFileSync(path, 'utf8'))[model];
|
|
294
|
+
if (!price) return null;
|
|
295
|
+
return ((usage.input ?? 0) * (price.entrada ?? 0) + (usage.output ?? 0) * (price.saida ?? 0)) / 1e6;
|
|
296
|
+
} catch { return null; }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Lasting effects and costs, summarised for the end of the dossier.
|
|
300
|
+
function marks(steps) {
|
|
301
|
+
const result = [];
|
|
302
|
+
const add = (label, key) => {
|
|
303
|
+
const found = result.find(item => item.key === key);
|
|
304
|
+
if (found) found.count++;
|
|
305
|
+
else result.push({ key, label, count: 1 });
|
|
306
|
+
};
|
|
307
|
+
const structure = { commands: 0, tables: new Set() };
|
|
308
|
+
for (const step of steps) {
|
|
309
|
+
if (step.type !== 'boundary') continue;
|
|
310
|
+
if (step.kind === 'base-de-dados' && STRUCTURE.has(step.operation)) {
|
|
311
|
+
structure.commands++;
|
|
312
|
+
for (const table of step.tables ?? []) structure.tables.add(table);
|
|
313
|
+
} else if (step.kind === 'base-de-dados' && WRITES.has(step.operation)) add(`escrita em ${step.tables?.join(', ') || 'base de dados'} (${step.operation})`, `db:${step.operation}:${step.tables}`);
|
|
314
|
+
else if (step.kind === 'ia') {
|
|
315
|
+
const model = step.result?.model ?? step.model;
|
|
316
|
+
const usage = step.result?.usage;
|
|
317
|
+
add(`chamada de IA a ${step.provider}${model ? ` (${model})` : ''}${usage ? ` · ${usage.input ?? '?'} + ${usage.output ?? '?'} tokens` : ''}${step.costUsd != null ? ` · ~US$ ${step.costUsd.toFixed(4)}` : ''}`, `ia:${step.id}`);
|
|
318
|
+
}
|
|
319
|
+
else if (step.kind === 'email' || step.kind === 'mensagem') add(`${step.kind === 'email' ? 'email' : 'mensagem'} enviado por ${step.provider ?? step.library}`, `mail:${step.provider}`);
|
|
320
|
+
else if (step.kind === 'pagamento') add(`pagamento ${step.provider} (${step.mode})`, `pay:${step.operation}`);
|
|
321
|
+
else if (step.kind === 'ficheiros' && step.operation !== 'leitura' && step.operation !== 'verificação') add(`ficheiro: ${step.operation} em ${step.bucket ?? step.provider}`, `file:${step.operation}:${step.bucket}`);
|
|
322
|
+
else if (step.kind === 'http' && !step.local) add(`chamada externa a ${step.host}`, `http:${step.host}`);
|
|
323
|
+
}
|
|
324
|
+
if (structure.commands) result.push({ key: 'db:structure', count: 1,
|
|
325
|
+
label: `estrutura da base de dados: ${structure.commands} comandos (CREATE/ALTER/DROP) em ${structure.tables.size} tabelas` });
|
|
326
|
+
return result;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// A file can only be shown if a function from it was recorded in that run.
|
|
330
|
+
const recorded = new Map();
|
|
331
|
+
function recordedFile(run, file) {
|
|
332
|
+
const key = `${run}\n${file}`;
|
|
333
|
+
// Only a positive answer is kept: a file may be recorded later.
|
|
334
|
+
if (recorded.has(key)) return true;
|
|
335
|
+
const found = recordedFileQuery(run, file);
|
|
336
|
+
if (found) recorded.set(key, true);
|
|
337
|
+
return found;
|
|
338
|
+
}
|
|
339
|
+
function recordedFileQuery(run, file) {
|
|
340
|
+
const pattern = `%"file":${JSON.stringify(file)}%`.replace(/[\\_]/g, match => `\\${match}`);
|
|
341
|
+
return Boolean(db.prepare(`select 1 from events e join requests r on r.request_id = e.request_id
|
|
342
|
+
where r.run = ? and e.type = 'enter' and e.data like ? escape '\\' limit 1`).get(run, pattern));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// The lowest level among the recordings of an action (minimal wins), with its reason.
|
|
346
|
+
function runLevel(runs) {
|
|
347
|
+
const rows = runs.map(run => db.prepare('select level, reason from runs where run = ?').get(run)).filter(Boolean);
|
|
348
|
+
const low = rows.find(row => row.level === 'minimo');
|
|
349
|
+
return { level: low?.level ?? rows[0]?.level ?? 'normal', reason: low?.reason ?? null };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function root(run) {
|
|
353
|
+
return db.prepare('select root from runs where run = ?').get(run)?.root ?? null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Browser positions already resolved to project files, so that a dossier
|
|
357
|
+
// keeps its locations after the application (and its source maps) stops.
|
|
358
|
+
const originGet = db.prepare('select data from origins where key = ?');
|
|
359
|
+
const originSet = db.prepare('insert or replace into origins(key, data) values (?, ?)');
|
|
360
|
+
function cachedOrigin(key) {
|
|
361
|
+
const row = originGet.get(key);
|
|
362
|
+
return row ? JSON.parse(row.data) : null;
|
|
363
|
+
}
|
|
364
|
+
function cacheOrigin(key, value) { originSet.run(key, JSON.stringify(value)); }
|
|
365
|
+
|
|
366
|
+
// Answers of the AI model, by prompt: the same function with the same facts
|
|
367
|
+
// is not sent again.
|
|
368
|
+
const purposeGet = db.prepare('select data from purposes where key = ?');
|
|
369
|
+
const purposeSet = db.prepare('insert or replace into purposes(key, data, at) values (?, ?, ?)');
|
|
370
|
+
const purposeCache = {
|
|
371
|
+
get: key => { const row = purposeGet.get(key); return row ? JSON.parse(row.data) : null; },
|
|
372
|
+
set: (key, value) => purposeSet.run(key, JSON.stringify(value), Date.now()),
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
return { ingest, listRuns, cachedOrigin, cacheOrigin, purposeCache, listRequests, listActions, actionDossier, dossier, root, recordedFile, close: () => db.close() };
|
|
376
|
+
}
|