Botão Commit & Push nos projetos e correção do resume de sessão
- Adicionar botão de commit & push para cada projeto na página de arquivos - Criar rota POST /api/files/commit-push com git add, commit e push - Adicionar Modal.prompt reutilizável para inputs com valor padrão - Corrigir detecção de erro no executor (is_error/errors do CLI) - Fallback automático para nova execução quando sessão expira no resume
This commit is contained in:
@@ -1327,6 +1327,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="prompt-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="prompt-modal-title" hidden>
|
||||||
|
<div class="modal modal--sm">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 class="modal-title" id="prompt-modal-title">Prompt</h2>
|
||||||
|
<button class="modal-close" type="button" aria-label="Fechar modal" data-modal-close="prompt-modal-overlay">
|
||||||
|
<i data-lucide="x"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p id="prompt-modal-message"></p>
|
||||||
|
<input type="text" class="form-input" id="prompt-modal-input" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn--ghost" type="button" id="prompt-modal-cancel-btn">Cancelar</button>
|
||||||
|
<button class="btn btn--primary" type="button" id="prompt-modal-confirm-btn">Confirmar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="export-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="export-modal-title" hidden>
|
<div class="modal-overlay" id="export-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="export-modal-title" hidden>
|
||||||
<div class="modal modal--md">
|
<div class="modal modal--md">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
|
|||||||
@@ -5320,6 +5320,8 @@ body, .sidebar, .header, .card, .modal-content, .input, .select, textarea, .metr
|
|||||||
.repo-selector { display: flex; gap: 8px; }
|
.repo-selector { display: flex; gap: 8px; }
|
||||||
.repo-selector .select { flex: 1; }
|
.repo-selector .select { flex: 1; }
|
||||||
|
|
||||||
|
.btn-commit-push { color: var(--warning); }
|
||||||
|
.btn-commit-push:hover { background: rgba(245, 158, 11, 0.1); }
|
||||||
.btn-publish { color: var(--success); }
|
.btn-publish { color: var(--success); }
|
||||||
.btn-publish:hover { background: rgba(16, 185, 129, 0.1); }
|
.btn-publish:hover { background: rgba(16, 185, 129, 0.1); }
|
||||||
|
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ const API = {
|
|||||||
list(path) { return API.request('GET', `/files${path ? '?path=' + encodeURIComponent(path) : ''}`); },
|
list(path) { return API.request('GET', `/files${path ? '?path=' + encodeURIComponent(path) : ''}`); },
|
||||||
delete(path) { return API.request('DELETE', `/files?path=${encodeURIComponent(path)}`); },
|
delete(path) { return API.request('DELETE', `/files?path=${encodeURIComponent(path)}`); },
|
||||||
publish(path) { return API.request('POST', '/files/publish', { path }); },
|
publish(path) { return API.request('POST', '/files/publish', { path }); },
|
||||||
|
commitPush(path, message) { return API.request('POST', '/files/commit-push', { path, message }); },
|
||||||
},
|
},
|
||||||
|
|
||||||
reports: {
|
reports: {
|
||||||
|
|||||||
@@ -783,6 +783,7 @@ const App = {
|
|||||||
case 'navigate-files': FilesUI.navigate(path || ''); break;
|
case 'navigate-files': FilesUI.navigate(path || ''); break;
|
||||||
case 'download-file': FilesUI.downloadFile(path); break;
|
case 'download-file': FilesUI.downloadFile(path); break;
|
||||||
case 'download-folder': FilesUI.downloadFolder(path); break;
|
case 'download-folder': FilesUI.downloadFolder(path); break;
|
||||||
|
case 'commit-push': FilesUI.commitPush(path); break;
|
||||||
case 'publish-project': FilesUI.publishProject(path); break;
|
case 'publish-project': FilesUI.publishProject(path); break;
|
||||||
case 'delete-entry': FilesUI.deleteEntry(path, el.dataset.entryType); break;
|
case 'delete-entry': FilesUI.deleteEntry(path, el.dataset.entryType); break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,11 +91,14 @@ const FilesUI = {
|
|||||||
? `<button class="btn btn--ghost btn--sm" data-action="download-folder" data-path="${Utils.escapeHtml(fullPath)}" title="Baixar pasta"><i data-lucide="download" style="width:14px;height:14px"></i></button>`
|
? `<button class="btn btn--ghost btn--sm" data-action="download-folder" data-path="${Utils.escapeHtml(fullPath)}" title="Baixar pasta"><i data-lucide="download" style="width:14px;height:14px"></i></button>`
|
||||||
: `<button class="btn btn--ghost btn--sm" data-action="download-file" data-path="${Utils.escapeHtml(fullPath)}" title="Baixar arquivo"><i data-lucide="download" style="width:14px;height:14px"></i></button>`;
|
: `<button class="btn btn--ghost btn--sm" data-action="download-file" data-path="${Utils.escapeHtml(fullPath)}" title="Baixar arquivo"><i data-lucide="download" style="width:14px;height:14px"></i></button>`;
|
||||||
const isRootDir = entry.type === 'directory' && !currentPath;
|
const isRootDir = entry.type === 'directory' && !currentPath;
|
||||||
|
const commitPushBtn = isRootDir
|
||||||
|
? `<button class="btn btn--ghost btn--sm btn-commit-push" data-action="commit-push" data-path="${Utils.escapeHtml(fullPath)}" title="Commit & Push para o Gitea"><i data-lucide="git-commit-horizontal" style="width:14px;height:14px"></i></button>`
|
||||||
|
: '';
|
||||||
const publishBtn = isRootDir
|
const publishBtn = isRootDir
|
||||||
? `<button class="btn btn--ghost btn--sm btn-publish" data-action="publish-project" data-path="${Utils.escapeHtml(fullPath)}" title="Publicar projeto"><i data-lucide="rocket" style="width:14px;height:14px"></i></button>`
|
? `<button class="btn btn--ghost btn--sm btn-publish" data-action="publish-project" data-path="${Utils.escapeHtml(fullPath)}" title="Publicar projeto"><i data-lucide="rocket" style="width:14px;height:14px"></i></button>`
|
||||||
: '';
|
: '';
|
||||||
const deleteBtn = `<button class="btn btn--ghost btn--sm btn-danger" data-action="delete-entry" data-path="${Utils.escapeHtml(fullPath)}" data-entry-type="${entry.type}" title="Excluir"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button>`;
|
const deleteBtn = `<button class="btn btn--ghost btn--sm btn-danger" data-action="delete-entry" data-path="${Utils.escapeHtml(fullPath)}" data-entry-type="${entry.type}" title="Excluir"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button>`;
|
||||||
const actions = `${downloadBtn}${publishBtn}${deleteBtn}`;
|
const actions = `${downloadBtn}${commitPushBtn}${publishBtn}${deleteBtn}`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr class="files-row">
|
<tr class="files-row">
|
||||||
@@ -188,6 +191,29 @@ const FilesUI = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async commitPush(path) {
|
||||||
|
const name = path.split('/').pop();
|
||||||
|
const message = await Modal.prompt(
|
||||||
|
'Commit & Push',
|
||||||
|
`Mensagem do commit para <strong>${name}</strong>:`,
|
||||||
|
`Atualização - ${new Date().toLocaleDateString('pt-BR')} ${new Date().toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}`
|
||||||
|
);
|
||||||
|
if (message === null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Toast.info('Realizando commit e push...');
|
||||||
|
const result = await API.files.commitPush(path, message || undefined);
|
||||||
|
|
||||||
|
if (result.status === 'clean') {
|
||||||
|
Toast.info(result.message);
|
||||||
|
} else {
|
||||||
|
Toast.success(`${result.changes} arquivo(s) enviados ao Gitea`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error(`Erro no commit/push: ${err.message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async deleteEntry(path, entryType) {
|
async deleteEntry(path, entryType) {
|
||||||
const label = entryType === 'directory' ? 'pasta' : 'arquivo';
|
const label = entryType === 'directory' ? 'pasta' : 'arquivo';
|
||||||
const name = path.split('/').pop();
|
const name = path.split('/').pop();
|
||||||
|
|||||||
@@ -58,6 +58,33 @@ const Modal = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_promptResolve: null,
|
||||||
|
|
||||||
|
prompt(title, message, defaultValue = '') {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
Modal._promptResolve = resolve;
|
||||||
|
|
||||||
|
const titleEl = document.getElementById('prompt-modal-title');
|
||||||
|
const messageEl = document.getElementById('prompt-modal-message');
|
||||||
|
const inputEl = document.getElementById('prompt-modal-input');
|
||||||
|
|
||||||
|
if (titleEl) titleEl.textContent = title;
|
||||||
|
if (messageEl) messageEl.innerHTML = message;
|
||||||
|
if (inputEl) inputEl.value = defaultValue;
|
||||||
|
|
||||||
|
Modal.open('prompt-modal-overlay');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_resolvePrompt(result) {
|
||||||
|
const inputEl = document.getElementById('prompt-modal-input');
|
||||||
|
Modal.close('prompt-modal-overlay');
|
||||||
|
if (Modal._promptResolve) {
|
||||||
|
Modal._promptResolve(result ? (inputEl?.value || '') : null);
|
||||||
|
Modal._promptResolve = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
_setupListeners() {
|
_setupListeners() {
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
if (e.target.classList.contains('modal-overlay')) {
|
if (e.target.classList.contains('modal-overlay')) {
|
||||||
@@ -65,6 +92,8 @@ const Modal = {
|
|||||||
|
|
||||||
if (modalId === 'confirm-modal-overlay') {
|
if (modalId === 'confirm-modal-overlay') {
|
||||||
Modal._resolveConfirm(false);
|
Modal._resolveConfirm(false);
|
||||||
|
} else if (modalId === 'prompt-modal-overlay') {
|
||||||
|
Modal._resolvePrompt(false);
|
||||||
} else {
|
} else {
|
||||||
Modal.close(modalId);
|
Modal.close(modalId);
|
||||||
}
|
}
|
||||||
@@ -77,6 +106,8 @@ const Modal = {
|
|||||||
|
|
||||||
if (targetId === 'confirm-modal-overlay') {
|
if (targetId === 'confirm-modal-overlay') {
|
||||||
Modal._resolveConfirm(false);
|
Modal._resolveConfirm(false);
|
||||||
|
} else if (targetId === 'prompt-modal-overlay') {
|
||||||
|
Modal._resolvePrompt(false);
|
||||||
} else {
|
} else {
|
||||||
Modal.close(targetId);
|
Modal.close(targetId);
|
||||||
}
|
}
|
||||||
@@ -91,6 +122,8 @@ const Modal = {
|
|||||||
|
|
||||||
if (activeModal.id === 'confirm-modal-overlay') {
|
if (activeModal.id === 'confirm-modal-overlay') {
|
||||||
Modal._resolveConfirm(false);
|
Modal._resolveConfirm(false);
|
||||||
|
} else if (activeModal.id === 'prompt-modal-overlay') {
|
||||||
|
Modal._resolvePrompt(false);
|
||||||
} else {
|
} else {
|
||||||
Modal.close(activeModal.id);
|
Modal.close(activeModal.id);
|
||||||
}
|
}
|
||||||
@@ -98,6 +131,17 @@ const Modal = {
|
|||||||
|
|
||||||
const confirmBtn = document.getElementById('confirm-modal-confirm-btn');
|
const confirmBtn = document.getElementById('confirm-modal-confirm-btn');
|
||||||
if (confirmBtn) confirmBtn.addEventListener('click', () => Modal._resolveConfirm(true));
|
if (confirmBtn) confirmBtn.addEventListener('click', () => Modal._resolveConfirm(true));
|
||||||
|
|
||||||
|
const promptConfirmBtn = document.getElementById('prompt-modal-confirm-btn');
|
||||||
|
if (promptConfirmBtn) promptConfirmBtn.addEventListener('click', () => Modal._resolvePrompt(true));
|
||||||
|
|
||||||
|
const promptCancelBtn = document.getElementById('prompt-modal-cancel-btn');
|
||||||
|
if (promptCancelBtn) promptCancelBtn.addEventListener('click', () => Modal._resolvePrompt(false));
|
||||||
|
|
||||||
|
const promptInput = document.getElementById('prompt-modal-input');
|
||||||
|
if (promptInput) promptInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') Modal._resolvePrompt(true);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -232,6 +232,8 @@ function processChildOutput(child, executionId, callbacks, options = {}) {
|
|||||||
durationApiMs: parsed.duration_api_ms || 0,
|
durationApiMs: parsed.duration_api_ms || 0,
|
||||||
numTurns: parsed.num_turns || 0,
|
numTurns: parsed.num_turns || 0,
|
||||||
sessionId: parsed.session_id || sessionIdOverride || '',
|
sessionId: parsed.session_id || sessionIdOverride || '',
|
||||||
|
isError: parsed.is_error || false,
|
||||||
|
errors: parsed.errors || [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -267,6 +269,13 @@ function processChildOutput(child, executionId, callbacks, options = {}) {
|
|||||||
activeExecutions.delete(executionId);
|
activeExecutions.delete(executionId);
|
||||||
if (hadError) return;
|
if (hadError) return;
|
||||||
if (outputBuffer.trim()) processEvent(parseStreamLine(outputBuffer));
|
if (outputBuffer.trim()) processEvent(parseStreamLine(outputBuffer));
|
||||||
|
|
||||||
|
if (resultMeta?.isError && resultMeta.errors?.length > 0) {
|
||||||
|
const errorMsg = resultMeta.errors.join('; ');
|
||||||
|
if (onError) onError(new Error(errorMsg), executionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (onComplete) {
|
if (onComplete) {
|
||||||
onComplete({
|
onComplete({
|
||||||
executionId,
|
executionId,
|
||||||
|
|||||||
@@ -420,20 +420,11 @@ export function continueConversation(agentId, sessionId, message, wsCallback) {
|
|||||||
parentSessionId: sessionId,
|
parentSessionId: sessionId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const executionId = executor.resume(
|
const onData = (parsed, execId) => {
|
||||||
agent.config,
|
|
||||||
sessionId,
|
|
||||||
message,
|
|
||||||
{
|
|
||||||
onData: (parsed, execId) => {
|
|
||||||
if (cb) cb({ type: 'execution_output', executionId: execId, agentId, data: parsed });
|
if (cb) cb({ type: 'execution_output', executionId: execId, agentId, data: parsed });
|
||||||
},
|
};
|
||||||
onError: (err, execId) => {
|
|
||||||
const endedAt = new Date().toISOString();
|
const onComplete = (result, execId) => {
|
||||||
executionsStore.update(historyRecord.id, { status: 'error', error: err.message, endedAt });
|
|
||||||
if (cb) cb({ type: 'execution_error', executionId: execId, agentId, data: { error: err.message } });
|
|
||||||
},
|
|
||||||
onComplete: (result, execId) => {
|
|
||||||
const endedAt = new Date().toISOString();
|
const endedAt = new Date().toISOString();
|
||||||
executionsStore.update(historyRecord.id, {
|
executionsStore.update(historyRecord.id, {
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
@@ -453,9 +444,46 @@ export function continueConversation(agentId, sessionId, message, wsCallback) {
|
|||||||
if (cb) cb({ type: 'report_generated', executionId: execId, agentId, reportFile: report.filename });
|
if (cb) cb({ type: 'report_generated', executionId: execId, agentId, reportFile: report.filename });
|
||||||
}
|
}
|
||||||
} catch (e) { console.error('[manager] Erro ao gerar relatório:', e.message); }
|
} catch (e) { console.error('[manager] Erro ao gerar relatório:', e.message); }
|
||||||
if (cb) cb({ type: 'execution_complete', executionId: execId, agentId, data: result });
|
if (cb) cb({ type: 'execution_complete', executionId: execId, agentId, agentName: agent.agent_name, data: result });
|
||||||
},
|
};
|
||||||
|
|
||||||
|
const onError = (err, execId) => {
|
||||||
|
const isSessionLost = err.message.includes('No conversation found') || err.message.includes('not a valid');
|
||||||
|
|
||||||
|
if (isSessionLost) {
|
||||||
|
console.log(`[manager] Sessão perdida (${sessionId}), iniciando nova execução para agente ${agentId}`);
|
||||||
|
if (cb) cb({ type: 'execution_output', executionId: execId, agentId, data: { type: 'system', content: 'Sessão anterior expirou. Iniciando nova execução...' } });
|
||||||
|
|
||||||
|
const secrets = secretsStore.getByAgent(agentId);
|
||||||
|
const newExecId = executor.execute(
|
||||||
|
agent.config,
|
||||||
|
{ description: message },
|
||||||
|
{ onData, onError: onErrorFinal, onComplete },
|
||||||
|
Object.keys(secrets).length > 0 ? secrets : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newExecId) {
|
||||||
|
executionsStore.update(historyRecord.id, { executionId: newExecId, parentSessionId: null });
|
||||||
|
} else {
|
||||||
|
onErrorFinal(new Error('Falha ao iniciar nova execução'), execId);
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onErrorFinal(err, execId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onErrorFinal = (err, execId) => {
|
||||||
|
const endedAt = new Date().toISOString();
|
||||||
|
executionsStore.update(historyRecord.id, { status: 'error', error: err.message, endedAt });
|
||||||
|
if (cb) cb({ type: 'execution_error', executionId: execId, agentId, data: { error: err.message } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const executionId = executor.resume(
|
||||||
|
agent.config,
|
||||||
|
sessionId,
|
||||||
|
message,
|
||||||
|
{ onData, onError, onComplete }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!executionId) {
|
if (!executionId) {
|
||||||
|
|||||||
@@ -1202,6 +1202,47 @@ router.get('/repos/:name/branches', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('/files/commit-push', async (req, res) => {
|
||||||
|
const { path: projectPath, message } = req.body;
|
||||||
|
if (!projectPath) return res.status(400).json({ error: 'path é obrigatório' });
|
||||||
|
|
||||||
|
const targetPath = resolveProjectPath(projectPath);
|
||||||
|
if (!targetPath) return res.status(400).json({ error: 'Caminho inválido' });
|
||||||
|
if (!existsSync(targetPath)) return res.status(404).json({ error: 'Projeto não encontrado' });
|
||||||
|
if (!statSync(targetPath).isDirectory()) return res.status(400).json({ error: 'Caminho não é uma pasta' });
|
||||||
|
|
||||||
|
const gitDir = `${targetPath}/.git`;
|
||||||
|
if (!existsSync(gitDir)) return res.status(400).json({ error: 'Projeto não possui repositório git inicializado' });
|
||||||
|
|
||||||
|
const exec = (cmd, opts = {}) => new Promise((resolve, reject) => {
|
||||||
|
const proc = spawnProcess('sh', ['-c', cmd], { cwd: opts.cwd || targetPath, env: { ...process.env, HOME: '/tmp', GIT_TERMINAL_PROMPT: '0' } });
|
||||||
|
let stdout = '', stderr = '';
|
||||||
|
proc.stdout.on('data', d => stdout += d);
|
||||||
|
proc.stderr.on('data', d => stderr += d);
|
||||||
|
proc.on('close', code => code === 0 ? resolve(stdout.trim()) : reject(new Error(stderr.trim() || `exit ${code}`)));
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const status = await exec('git status --porcelain');
|
||||||
|
if (!status) return res.json({ status: 'clean', message: 'Nenhuma alteração para commitar', changes: 0 });
|
||||||
|
|
||||||
|
const changes = status.split('\n').filter(l => l.trim()).length;
|
||||||
|
|
||||||
|
await exec('git add -A');
|
||||||
|
|
||||||
|
const commitMsg = message || `Atualização automática - ${new Date().toLocaleDateString('pt-BR')} ${new Date().toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}`;
|
||||||
|
await exec(`git -c user.name="Agents Orchestrator" -c user.email="agents@nitro-cloud" commit -m "${commitMsg.replace(/"/g, '\\"')}"`);
|
||||||
|
|
||||||
|
await exec('git push origin HEAD:main');
|
||||||
|
|
||||||
|
const log = await exec('git log -1 --format="%h %s"');
|
||||||
|
|
||||||
|
res.json({ status: 'pushed', message: `Commit e push realizados: ${log}`, changes, commit: log });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.post('/files/publish', async (req, res) => {
|
router.post('/files/publish', async (req, res) => {
|
||||||
const { path: projectPath } = req.body;
|
const { path: projectPath } = req.body;
|
||||||
if (!projectPath) return res.status(400).json({ error: 'path é obrigatório' });
|
if (!projectPath) return res.status(400).json({ error: 'path é obrigatório' });
|
||||||
|
|||||||
Reference in New Issue
Block a user