codetac 0.1.0 → 0.2.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/src/panel.mjs CHANGED
@@ -32,6 +32,8 @@ function source(run, file, line, endLine) {
32
32
  // Never hidden files (.env and similar), and only files with recorded functions.
33
33
  if (within.split(sep).some(part => part.startsWith('.') && part !== '.next')) return null;
34
34
  if (!store.recordedFile(run, file) && !view.browserFile(run, real)) return null;
35
+ // A step without a line (opaque) has no code to show.
36
+ if (!Number.isFinite(line) || line < 1) return null;
35
37
  const lines = readFileSync(real, 'utf8').split('\n');
36
38
  const start = Math.max(1, line);
37
39
  const end = Math.min(lines.length, endLine && endLine >= start ? Math.min(endLine, start + 400) : start + 40);
@@ -202,7 +204,7 @@ const page = `<!doctype html>
202
204
  <head>
203
205
  <meta charset="utf-8">
204
206
  <meta name="viewport" content="width=device-width, initial-scale=1">
205
- <title>CodeTAC — dossiês</title>
207
+ <title>CodeTAC — dossiers</title>
206
208
  <style>
207
209
  :root { --bg:#f7f7f5; --panel:#fff; --text:#1d1d1b; --muted:#6b6b66; --line:#e4e3de; --accent:#2f5bd3;
208
210
  --db:#0f7b5f; --http:#6a4bc4; --ia:#b4531f; --mail:#1f73b4; --pay:#9b2c86; --file:#7a6a12; --auth:#3d6b2f; --error:#c0392b; --code:#f1f0ec;
@@ -390,6 +392,10 @@ function relative(root, file) { return root && file && file.startsWith(root + '/
390
392
  // functions with steps inside open with the arrow.
391
393
  // Rows keep what the step box needs (code, values, questions) by id.
392
394
  const stepData = new Map();
395
+ // Work done after the response was sent (FastAPI BackgroundTasks, teardown).
396
+ function afterTag(n) {
397
+ return n.afterResponse ? ' <span class="tag" title="Correu depois de a resposta ter sido enviada ao browser (por exemplo, tarefas em segundo plano).">depois da resposta</span>' : '';
398
+ }
393
399
  function editorLink(file, line) {
394
400
  if (!aiConfig || !aiConfig.editor || !file || file[0] !== '/') return '';
395
401
  return '<a class="mini" href="' + esc(aiConfig.editor + '://file' + encodeURI(file) + ':' + (line || 1) + ':1') + '" title="Abrir no editor na linha ' + esc(line) + '">abrir no editor</a>';
@@ -420,19 +426,21 @@ function rowHtml(n, c, depth, open) {
420
426
  if (n.type === 'group') {
421
427
  row = '<div class="step grp' + (n.errors ? ' warn' : '') + '" ' + pad + '>' + caret + '<span class="body"><span class="tag k-grupo">' + n.count + ' passos</span><span class="sent">' + esc(n.sentence) + '</span></span><span class="time">' + ms(n.durationMs) + '</span></div>';
422
428
  } else if (n.type === 'function') {
423
- const where = n.file ? relative(c.root, n.file) + ':' + n.line : '';
429
+ // Without a line (a template whose lines could not be mapped): the file only.
430
+ const where = n.file ? relative(c.root, n.file) + (n.line != null ? ':' + n.line : ' (sem linhas)') : '';
424
431
  // A function with nothing inside is named in its parent's sentence ("chama …"):
425
432
  // the grouped view leaves it out; "Ver tudo" shows it in place.
426
433
  const internal = depth > 0 && !inner.length && !n.error && !n.detail;
427
434
  const on = asked(c, n);
428
435
  row = '<div class="step fn' + (n.error ? ' error' : '') + (internal ? ' internal' : '') + '" ' + pad + ' data-step="' + esc(n.id) + '">' + caret +
429
436
  '<span class="body"><span class="sent" data-node="' + esc(n.id) + '" data-facts="' + esc(n.purpose.text) + '">' + esc(n.purpose.text) + '</span>' +
430
- '<span class="where" title="' + esc(where) + '">' + esc(n.function) + ' · ' + esc(where.split('/').pop()) + '</span>' + (n.error ? ' <span class="tag" style="color:var(--error)">erro</span>' : '') +
431
- '<span class="acts"><button class="mini' + (on ? ' on' : '') + '" data-detalhe="' + esc(n.id) + '" title="' + (on ? 'Detalhe pedido: a próxima execução grava os valores. Carregue para desligar.' : 'Gravar os valores de entrada e saída e as linhas executadas desta função, a partir da próxima ação') + '">' + (on ? 'detalhe ligado' : 'pedir detalhe') + '</button>' +
432
- editorLink(n.file, n.line) + '</span></span><span class="time">' + ms(n.durationMs) + '</span></div>' + (n.detail ? valuesHtml(n, valuesPad) : '');
437
+ '<span class="where" title="' + esc(where) + '">' + esc(n.function) + (where ? ' · ' + esc(where.split('/').pop()) : '') + '</span>' + (n.error ? ' <span class="tag" style="color:var(--error)">erro</span>' : '') +
438
+ (n.opaque ? ' <span class="tag" title="Esta parte corre, mas o CodeTAC não consegue ver o que faz por dentro.">opaco</span>' : '') + afterTag(n) +
439
+ (n.opaque ? '' : '<span class="acts"><button class="mini' + (on ? ' on' : '') + '" data-detalhe="' + esc(n.id) + '" title="' + (on ? 'Detalhe pedido: a próxima execução grava os valores. Carregue para desligar.' : 'Gravar os valores de entrada e saída e as linhas executadas desta função, a partir da próxima ação') + '">' + (on ? 'detalhe ligado' : 'pedir detalhe') + '</button>' +
440
+ editorLink(n.file, n.line) + '</span>') + '</span><span class="time">' + ms(n.durationMs) + '</span></div>' + (n.detail ? valuesHtml(n, valuesPad) : '');
433
441
  } else {
434
442
  const r = n.result || {};
435
- row = '<div class="step boundary' + (n.error || r.error ? ' error' : '') + '" ' + pad + ' data-step="' + esc(n.id) + '">' + caret + '<span class="body"><span class="tag k-' + esc(n.kind) + '">' + esc(kindLabel[n.kind] || n.kind) + '</span>' +
443
+ row = '<div class="step boundary' + (n.error || r.error ? ' error' : '') + '" ' + pad + ' data-step="' + esc(n.id) + '">' + caret + '<span class="body"><span class="tag k-' + esc(n.kind) + '">' + esc(kindLabel[n.kind] || n.kind) + '</span>' + afterTag(n) +
436
444
  '<span class="sent" data-node="' + esc(n.id) + '" data-facts="' + esc(n.purpose.text) + '">' + esc(n.purpose.text) + '</span>' +
437
445
  (n.sql ? '<span class="detail sql">' + esc(n.sql) + '</span>' : '') +
438
446
  (r.promptExcerpt || n.promptExcerpt ? '<span class="detail">pedido: «' + esc(r.promptExcerpt || n.promptExcerpt) + '»</span>' : '') +
@@ -491,6 +499,7 @@ const REASONS = {
491
499
  'generator-skipped': 'generators: correm, mas não aparecem na sequência',
492
500
  'parameter-redeclaration-skipped': 'funções que redeclaram um parâmetro: correm, mas não aparecem na sequência',
493
501
  'direct-eval-skipped': 'funções com eval direto: correm, mas não aparecem na sequência',
502
+ 'template-lines-unmapped': 'templates cujas linhas não foi possível ligar ao ficheiro: aparecem como um só passo, sem linhas',
494
503
  };
495
504
  function minimalHtml(d) {
496
505
  if (d.level !== 'minimo') return '';
@@ -595,6 +604,7 @@ async function loadAction(id) {
595
604
  let html = '<h2>' + esc(d.label) + '</h2><p class="summary" data-node="action" data-facts="' + esc(d.digest.summary) + '">' + esc(d.digest.summary) + '</p><p class="note">' + time(d.startedAt) + (d.durationMs ? ' · ação de ' + ms(d.durationMs) : '') +
596
605
  ' · no browser: elemento, componente, handler e pedidos (sem funções uma a uma) · no servidor: nível ' + esc(d.level) + '</p>';
597
606
  html += minimalHtml(d) + '<p id="ai"></p>' + TOOLBAR + detailStateHtml(run);
607
+ const explained = new Set(); // origin notes already explained in full in this action
598
608
  for (const item of d.digest.timeline) {
599
609
  if (item.type === 'dev-group') {
600
610
  html += '<div class="node closed"><div class="step grp"><button class="caret">▸</button><span class="body"><span class="tag k-grupo">' + item.items.length + '</span><span class="sent">' + esc(item.sentence) + '</span></span><span class="time"></span></div>' +
@@ -623,7 +633,9 @@ async function loadAction(id) {
623
633
  esc(b.method) + ' ' + esc(b.path) + (b.queryKeys && b.queryKeys.length ? '?' + b.queryKeys.map(k => esc(k) + '=…').join('&') : '') + '</span> ' +
624
634
  '<span class="where">' + (b.status != null ? 'estado ' + b.status : b.error ? 'falhou' : 'sem resposta') + ' · ' + ms(b.durationMs) + '</span>' +
625
635
  (chainHtml || via ? '<div class="meta">chamado por: ' + (chainHtml || '<span class="where">(sem posição resolvida no projeto)</span>') + via + '</div>' : '') + '</div>';
626
- html += serverHtml(item.server, b.sameOrigin, b);
636
+ const note = originNote(item, d.origin, explained.has(item.probable ? 'provavel' : 'sem'));
637
+ if (note) explained.add(item.probable ? 'provavel' : 'sem');
638
+ html += note + serverHtml(item.server, b.sameOrigin, b);
627
639
  } else if (item.type === 'navigation') {
628
640
  const text = item.kind === 'saída da página' ? 'saiu de ' + esc(item.path) + ' (o browser carrega um documento novo)' : esc(item.kind) + ' → ' + esc(item.path);
629
641
  html += '<div class="step plain"><span><span class="tag k-browser">navegação</span><span class="name">' + text + '</span></span><span class="time"></span></div>';
@@ -659,14 +671,38 @@ async function loadAction(id) {
659
671
  actionTimer = setTimeout(() => loadAction(id), 1500);
660
672
  } else startPurposes('actions', id);
661
673
  }
674
+ // DP3: a request of the page to another origin of the same machine (the API
675
+ // on another port, without a proxy), in plain language.
676
+ // The full explanation once per action; a short line for the next requests.
677
+ function originNote(item, pageOrigin, brief) {
678
+ const b = item.browser;
679
+ if (b.sameOrigin || !b.host) return '';
680
+ let page = '';
681
+ try { page = new URL(pageOrigin).hostname; } catch {}
682
+ const name = b.host.replace(/:\\d+$/, '').replace(/^\\[|\\]$/g, '');
683
+ const local = ['localhost', '127.0.0.1', '::1', page].includes(name);
684
+ const proxy = ' Para uma ligação exata, use o proxy do servidor de desenvolvimento (no Vite, <code>server.proxy</code>).';
685
+ if (item.probable && brief) return '<p class="note probable"><b>Ligação provável</b> (como acima).</p>';
686
+ if (!item.probable && local && brief) return '<p class="note probable"><b>Sem ligação ao servidor</b> (como acima).</p>';
687
+ if (item.probable) {
688
+ return '<p class="note probable"><b>Ligação provável.</b> O pedido foi para outra origem (' + esc(b.host) + '), onde o CodeTAC não o pode marcar sem mudar a app: ' +
689
+ 'foi ligado ao pedido que esse servidor recebeu desta página com o mesmo método e caminho, à mesma hora (pedidos iguais em simultâneo podem trocar-se).' + proxy + '</p>';
690
+ }
691
+ if (!local) return '';
692
+ return '<p class="note probable"><b>Sem ligação ao servidor.</b> O pedido foi para outra origem (' + esc(b.host) + ') e o CodeTAC não encontrou o pedido nesse servidor: ' +
693
+ 'pode não estar a ser observado (arranque-o também com o CodeTAC).' + proxy + '</p>';
694
+ }
695
+
662
696
  function serverHtml(list, sameOrigin, browser) {
663
697
  if (!list || !list.length) {
664
698
  return sameOrigin ? '<div class="server-part"><p class="note" style="margin:4px 0">O servidor não registou este pedido (servido sem captura, por exemplo por um ficheiro estático ou por outro processo).</p></div>' : '';
665
699
  }
666
700
  const same = d => browser && list.length === 1 && browser.method === d.request.method && browser.path === d.request.path && browser.status === d.request.status;
667
- return list.map(d => '<div class="server-part">' + (same(d) ? '' : '<div class="meta"><span class="tag k-servidor">servidor</span>' + esc(d.request.method) + ' ' + esc(d.request.path) +
668
- ' · estado ' + esc(d.request.status ?? 'em curso') + ' · ' + ms(d.request.durationMs) + '</div>') +
669
- (d.steps.length ? digestHtml(d) : '<p class="note" style="margin:4px 0">Nenhuma função do projeto nem fronteira neste pedido.</p>') + '</div>').join('');
701
+ // A part with nothing inside (a CORS preflight, the hop of a proxy) takes one line.
702
+ const empty = '<p class="note" style="margin:4px 0">Nenhuma função do projeto nem fronteira neste pedido.</p>';
703
+ return list.map(d => '<div class="server-part">' + (same(d) ? (d.steps.length ? '' : empty) : '<div class="meta"><span class="tag k-servidor">servidor</span>' + esc(d.request.method) + ' ' + esc(d.request.path) +
704
+ ' · estado ' + esc(d.request.status ?? 'em curso') + ' · ' + ms(d.request.durationMs) + (d.steps.length ? '' : ' · sem funções do projeto nem fronteiras') + '</div>') +
705
+ (d.steps.length ? digestHtml(d) : '') + '</div>').join('');
670
706
  }
671
707
 
672
708
  detail.addEventListener('click', async event => {
@@ -732,12 +768,17 @@ async function openStep(id) {
732
768
  const { node: n, c } = stepData.get(id);
733
769
  const box = document.getElementById('code');
734
770
  let html = '<section class="box">';
735
- if (n.type === 'function') {
771
+ if (n.type === 'function' && n.opaque) {
772
+ // Nothing to show inside: no code, no values.
773
+ html += '<h3>' + esc(n.function) + (n.file ? ' — ' + esc(relative(c.root, n.file)) : '') + '</h3>' +
774
+ '<p class="note" style="margin:2px 0 6px">' + esc(n.purpose.text) + '. O tempo e o resultado (erro ou não) são reais; o que se passa lá dentro não é visível.</p>';
775
+ } else if (n.type === 'function') {
736
776
  const params = new URLSearchParams({ run: c.run, file: n.file, line: n.line, end: n.endLine ?? '' });
737
- const result = await (await fetch('/api/source?' + params)).json();
777
+ const result = n.line == null ? { error: 'Sem linhas: o CodeTAC não conseguiu ligar este passo às linhas do ficheiro.' }
778
+ : await (await fetch('/api/source?' + params)).json();
738
779
  const on = asked(c, n);
739
780
  const wholeFile = (detailSpecs[c.run] || { files: [] }).files.includes(n.file);
740
- html += '<h3>' + esc(n.function) + ' — ' + esc(result.file || relative(c.root, n.file)) + ':' + n.line + ' ' + editorLink(n.file, n.line) + '</h3>' +
781
+ html += '<h3>' + esc(n.function) + ' — ' + esc(result.file || relative(c.root, n.file)) + (n.line != null ? ':' + n.line : '') + ' ' + editorLink(n.file, n.line ?? 1) + '</h3>' +
741
782
  '<p class="note" style="margin:2px 0 6px">' + esc(n.purpose.text) + '</p>' +
742
783
  '<div class="toolbar"><button data-detalhe="' + esc(n.id) + '">' + (on && !wholeFile ? 'Desligar o detalhe desta função' : 'Pedir detalhe desta função') + '</button>' +
743
784
  '<button data-ficheiro="' + esc(n.file) + '" data-run="' + esc(c.run) + '">' + (wholeFile ? 'Desligar o detalhe do ficheiro' : 'Pedir detalhe de todo o ficheiro') + '</button></div>';
@@ -771,7 +812,8 @@ async function askQuestion(id) {
771
812
  const a = await response.json();
772
813
  if (a.error) { out.innerHTML = '<p class="note">' + esc(a.error) + '</p>'; return; }
773
814
  if (!a.available) { out.innerHTML = '<p class="note">' + esc(a.text) + '</p>'; return; }
774
- const source = a.model ? 'Resposta de ' + a.model + (a.local ? ' (modelo local)' : '') + (a.valuesSent ? ', com os valores gravados.' : '.') : '';
815
+ const source = a.model ? 'Resposta de ' + a.model + (a.local ? ' (modelo local)' : '') + (a.valuesSent ? ', com os valores gravados.'
816
+ : a.valuesWithheld ? ', sem os valores gravados: não são enviados a modelos fora desta máquina (só as linhas executadas e o código redigido).' : '.') : '';
775
817
  out.innerHTML = '<div class="answer' + (a.known ? '' : ' unknown') + (a.rejected ? ' rejected' : '') + '">' +
776
818
  (a.rejected ? '<p class="note" style="color:var(--error);margin:0 0 4px">Resposta não verificada (' + esc(a.rejected) + '). Confirme no código.</p>' : '') +
777
819
  (!a.known ? '<p class="note" style="margin:0 0 4px">Os factos gravados não chegam para responder com certeza:</p>' : '') +
@@ -0,0 +1,88 @@
1
+ """CodeTAC capture for Python apps (FastAPI, Flask).
2
+
3
+ Loaded by sitecustomize.py only when CODETAC_RUN is set. It writes the same
4
+ JSONL events as the Node runtime (src/runtime.mjs), so the store, the panel
5
+ and the bar need no Python branches.
6
+
7
+ This file, writer.py and redact.py must stay importable on old Pythons
8
+ (3.8+): below the minimum the capture falls back to the minimal mode instead
9
+ of breaking the app. capture.py needs 3.12 (sys.monitoring).
10
+ """
11
+ import os
12
+ import re
13
+ import sys
14
+
15
+ MINIMUM = (3, 12)
16
+
17
+ level = None
18
+ root = None
19
+ reason = None
20
+ writer = None
21
+ monitor = None # capture.Capture, when the project's functions are followed
22
+ # 'supervisor' in a process that only watches files or manages workers (the
23
+ # reloader of werkzeug, uvicorn --reload, fastapi dev): it serves nothing.
24
+ role = 'app'
25
+
26
+
27
+ def start():
28
+ global level, reason, writer, monitor, root
29
+ run = os.environ.get('CODETAC_RUN', '')
30
+ if not re.match(r'^[a-zA-Z0-9_-]{1,80}$', run):
31
+ sys.stderr.write('codeTAC: CODETAC_RUN: use apenas letras, números, _ ou - (máximo 80). Captura desligada.\n')
32
+ return
33
+ from .writer import Writer, data_directory, install_signals
34
+ root = os.path.realpath(os.environ.get('CODETAC_ROOT') or os.getcwd())
35
+ writer = Writer(os.path.join(data_directory(), run))
36
+
37
+ if os.environ.get('CODETAC_LEVEL') == 'minimo':
38
+ level, reason = 'minimo', os.environ.get('CODETAC_MINIMO_MOTIVO') or 'pedido no arranque'
39
+ elif sys.version_info < MINIMUM:
40
+ level = 'minimo'
41
+ reason = 'o Python %d.%d não permite seguir as funções (é preciso o %d.%d ou mais recente)' % (
42
+ sys.version_info[:2] + MINIMUM)
43
+ else:
44
+ from .capture import Capture, NoToolId
45
+ try:
46
+ monitor = Capture(writer, root)
47
+ level, reason = 'normal', None
48
+ except NoToolId as error:
49
+ level, reason = 'minimo', str(error)
50
+
51
+ event = {'type': 'capture-start', 'root': root, 'python': '%d.%d.%d' % sys.version_info[:3], 'level': level}
52
+ if reason:
53
+ event['reason'] = reason
54
+ writer.start(event)
55
+ install_signals(writer.flush)
56
+ if monitor is not None:
57
+ monitor.start()
58
+ from .servers import install_hooks
59
+ install_hooks()
60
+ from .boundaries import install as install_boundaries
61
+ install_boundaries(root, data_directory())
62
+ from .frameworks import install as install_frameworks
63
+ install_frameworks()
64
+ if hasattr(os, 'register_at_fork'):
65
+ os.register_at_fork(after_in_child=_after_fork)
66
+
67
+
68
+ def mark_supervisor(server):
69
+ """This process supervises the one that serves: it stops following functions."""
70
+ global role
71
+ if role == 'supervisor':
72
+ return
73
+ role = 'supervisor'
74
+ if monitor is not None:
75
+ monitor.pause()
76
+ # Only when the process already left a file (it imported the app, as the
77
+ # reloader of flask run --debug does): it says why nothing else follows.
78
+ if writer.active:
79
+ writer.emit({'type': 'process', 'role': 'supervisor', 'server': server})
80
+
81
+
82
+ def _after_fork():
83
+ """A worker forked by a supervisor (gunicorn) serves: it follows functions again."""
84
+ global role
85
+ if role == 'supervisor':
86
+ role = 'app'
87
+ if monitor is not None:
88
+ monitor.resume()