feat(F-181): completed feature
This commit is contained in:
@@ -7081,13 +7081,15 @@
|
|||||||
"description": "POS customer search returns no clients and cashier must be able to create a customer from the register",
|
"description": "POS customer search returns no clients and cashier must be able to create a customer from the register",
|
||||||
"priority": "high",
|
"priority": "high",
|
||||||
"risk": "med",
|
"risk": "med",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"created_at": "2026-08-22",
|
"created_at": "2026-08-22",
|
||||||
"gates": {
|
"gates": {
|
||||||
"reviewer": false,
|
"reviewer": true,
|
||||||
"security": false,
|
"security": true,
|
||||||
"qa": false
|
"qa": true,
|
||||||
}
|
"close": true
|
||||||
|
},
|
||||||
|
"completed_at": "2026-08-22T17:10:02Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "F-182",
|
"id": "F-182",
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ interface Config {
|
|||||||
interface Customer {
|
interface Customer {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
firstName?: string;
|
displayName?: string | null;
|
||||||
lastName?: string;
|
phone?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
@@ -57,6 +57,10 @@ export default function RegisterPage() {
|
|||||||
const [showCustomerSearch, setShowCustomerSearch] = useState(false);
|
const [showCustomerSearch, setShowCustomerSearch] = useState(false);
|
||||||
const [customerQuery, setCustomerQuery] = useState('');
|
const [customerQuery, setCustomerQuery] = useState('');
|
||||||
const [customerResults, setCustomerResults] = useState<Customer[]>([]);
|
const [customerResults, setCustomerResults] = useState<Customer[]>([]);
|
||||||
|
const [customerMode, setCustomerMode] = useState<'search' | 'create'>('search');
|
||||||
|
const [newCustomer, setNewCustomer] = useState({ email: '', displayName: '', phone: '' });
|
||||||
|
const [customerError, setCustomerError] = useState('');
|
||||||
|
const [creatingCustomer, setCreatingCustomer] = useState(false);
|
||||||
const [processing, setProcessing] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
const [lastSale, setLastSale] = useState<{ orderId: string; totalCents: number } | null>(null);
|
const [lastSale, setLastSale] = useState<{ orderId: string; totalCents: number } | null>(null);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -196,13 +200,35 @@ export default function RegisterPage() {
|
|||||||
const handleCustomerSearch = async (q: string) => {
|
const handleCustomerSearch = async (q: string) => {
|
||||||
setCustomerQuery(q);
|
setCustomerQuery(q);
|
||||||
if (q.trim().length < 2) { setCustomerResults([]); return; }
|
if (q.trim().length < 2) { setCustomerResults([]); return; }
|
||||||
|
setCustomerError('');
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/pos/customers/search?q=${encodeURIComponent(q)}`, { credentials: 'include' });
|
const data = await posApi.searchCustomers(q) as { items: Customer[] };
|
||||||
if (res.ok) {
|
setCustomerResults(data.items ?? []);
|
||||||
const data = await res.json() as { items: Customer[] };
|
} catch (err) {
|
||||||
setCustomerResults(data.items ?? []);
|
setCustomerResults([]);
|
||||||
}
|
setCustomerError(err instanceof Error ? err.message : 'No se pudieron buscar clientes');
|
||||||
} catch { setCustomerResults([]); }
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createCustomer = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setCreatingCustomer(true);
|
||||||
|
setCustomerError('');
|
||||||
|
try {
|
||||||
|
const created = await posApi.createCustomer<Customer>({
|
||||||
|
email: newCustomer.email.trim(),
|
||||||
|
...(newCustomer.displayName.trim() ? { displayName: newCustomer.displayName.trim() } : {}),
|
||||||
|
...(newCustomer.phone.trim() ? { phone: newCustomer.phone.trim() } : {}),
|
||||||
|
});
|
||||||
|
setCustomer(created);
|
||||||
|
setShowCustomerSearch(false);
|
||||||
|
setCustomerMode('search');
|
||||||
|
setNewCustomer({ email: '', displayName: '', phone: '' });
|
||||||
|
} catch (err) {
|
||||||
|
setCustomerError(err instanceof Error ? err.message : 'No se pudo crear el cliente');
|
||||||
|
} finally {
|
||||||
|
setCreatingCustomer(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const completeSale = async (kind: 'cash' | 'card') => {
|
const completeSale = async (kind: 'cash' | 'card') => {
|
||||||
@@ -376,7 +402,7 @@ export default function RegisterPage() {
|
|||||||
<h2 className="text-lg font-bold" style={{ color: 'var(--color-primary)' }}>Carrito</h2>
|
<h2 className="text-lg font-bold" style={{ color: 'var(--color-primary)' }}>Carrito</h2>
|
||||||
{customer ? (
|
{customer ? (
|
||||||
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full">
|
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full">
|
||||||
{customer.firstName} {customer.lastName} ✕
|
{customer.displayName || customer.email} ✕
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<button onClick={() => setShowCustomerSearch(!showCustomerSearch)} className="text-xs text-blue-600 underline">
|
<button onClick={() => setShowCustomerSearch(!showCustomerSearch)} className="text-xs text-blue-600 underline">
|
||||||
@@ -386,23 +412,41 @@ export default function RegisterPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showCustomerSearch && (
|
{showCustomerSearch && (
|
||||||
<div className="mb-3 bg-white rounded-xl p-3 shadow">
|
<div className="mb-3 space-y-3 rounded-xl bg-white p-3 shadow">
|
||||||
<input
|
<div className="flex rounded-lg bg-gray-100 p-1 text-xs font-medium">
|
||||||
type="text"
|
<button type="button" onClick={() => { setCustomerMode('search'); setCustomerError(''); }} className={`flex-1 rounded-md py-1.5 ${customerMode === 'search' ? 'bg-white text-[#2D6A4F] shadow-sm' : 'text-gray-500'}`}>Buscar</button>
|
||||||
value={customerQuery}
|
<button type="button" onClick={() => { setCustomerMode('create'); setCustomerError(''); }} className={`flex-1 rounded-md py-1.5 ${customerMode === 'create' ? 'bg-white text-[#2D6A4F] shadow-sm' : 'text-gray-500'}`}>Crear cliente</button>
|
||||||
onChange={e => handleCustomerSearch(e.target.value)}
|
|
||||||
placeholder="Buscar cliente…"
|
|
||||||
className="w-full px-3 py-2 border rounded-lg text-sm mb-2"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
|
||||||
{customerResults.map(c => (
|
|
||||||
<button key={c.id} onClick={() => { setCustomer(c); setShowCustomerSearch(false); setCustomerQuery(''); setCustomerResults([]); }}
|
|
||||||
className="w-full text-left px-2 py-1 text-sm hover:bg-blue-50 rounded-lg">
|
|
||||||
{c.email} {c.firstName ? `(${c.firstName} ${c.lastName})` : ''}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
{customerMode === 'search' ? (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={customerQuery}
|
||||||
|
onChange={e => handleCustomerSearch(e.target.value)}
|
||||||
|
placeholder="Email, nombre o teléfono…"
|
||||||
|
className="w-full rounded-lg border px-3 py-2 text-sm"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="max-h-36 space-y-1 overflow-y-auto">
|
||||||
|
{customerResults.map(c => (
|
||||||
|
<button key={c.id} onClick={() => { setCustomer(c); setShowCustomerSearch(false); setCustomerQuery(''); setCustomerResults([]); }}
|
||||||
|
className="w-full rounded-lg px-2 py-2 text-left text-sm hover:bg-blue-50">
|
||||||
|
<span className="block font-medium">{c.displayName || c.email}</span>
|
||||||
|
{c.displayName && <span className="block text-xs text-gray-500">{c.email}{c.phone ? ` · ${c.phone}` : ''}</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{customerQuery.trim().length >= 2 && customerResults.length === 0 && !customerError && <p className="py-2 text-center text-xs text-gray-400">Sin coincidencias</p>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={createCustomer} className="space-y-2">
|
||||||
|
<input type="email" required value={newCustomer.email} onChange={e => setNewCustomer({ ...newCustomer, email: e.target.value })} placeholder="Email *" className="w-full rounded-lg border px-3 py-2 text-sm" autoFocus />
|
||||||
|
<input value={newCustomer.displayName} onChange={e => setNewCustomer({ ...newCustomer, displayName: e.target.value })} placeholder="Nombre" className="w-full rounded-lg border px-3 py-2 text-sm" />
|
||||||
|
<input type="tel" value={newCustomer.phone} onChange={e => setNewCustomer({ ...newCustomer, phone: e.target.value })} placeholder="Teléfono" className="w-full rounded-lg border px-3 py-2 text-sm" />
|
||||||
|
<button disabled={creatingCustomer} className="w-full rounded-lg bg-[#2D6A4F] py-2 text-sm font-semibold text-white disabled:opacity-50">{creatingCustomer ? 'Creando…' : 'Crear y asociar'}</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{customerError && <p className="text-xs text-red-600">{customerError}</p>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ export const posApi = {
|
|||||||
productByEan: (ean: string) => apiFetch(`/pos/products/by-ean/${encodeURIComponent(ean)}`),
|
productByEan: (ean: string) => apiFetch(`/pos/products/by-ean/${encodeURIComponent(ean)}`),
|
||||||
/** Get product by SKU. */
|
/** Get product by SKU. */
|
||||||
productBySku: (sku: string) => apiFetch(`/pos/products/by-sku/${encodeURIComponent(sku)}`),
|
productBySku: (sku: string) => apiFetch(`/pos/products/by-sku/${encodeURIComponent(sku)}`),
|
||||||
|
/** Search identity customers for sale association. */
|
||||||
|
searchCustomers: (q: string) => apiFetch(`/pos/customers/search?q=${encodeURIComponent(q)}`),
|
||||||
|
/** Create an identity customer from the register. */
|
||||||
|
createCustomer: <T>(data: { email: string; displayName?: string; phone?: string }) =>
|
||||||
|
apiFetch<T>('/pos/customers', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
|
|||||||
@@ -826,17 +826,79 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
|
|||||||
const { q, limit = 10 } = request.query as { q?: string; limit?: number };
|
const { q, limit = 10 } = request.query as { q?: string; limit?: number };
|
||||||
if (!q || q.trim().length < 2) return reply.send({ items: [] });
|
if (!q || q.trim().length < 2) return reply.send({ items: [] });
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT u.id, u.email, p.first_name AS "firstName", p.last_name AS "lastName", p.phone
|
`SELECT u.id, u.email, p.display_name AS "displayName", COALESCE(p.phone, u.phone) AS phone
|
||||||
FROM identity_users u
|
FROM identity_users u
|
||||||
LEFT JOIN users_profiles p ON p.user_id = u.id
|
LEFT JOIN users_profiles p ON p.user_id = u.id
|
||||||
WHERE u.email ILIKE $1 OR p.first_name ILIKE $1 OR p.last_name ILIKE $1 OR p.phone ILIKE $1
|
WHERE u.role = 'customer'
|
||||||
ORDER BY p.last_name LIMIT $2`,
|
AND (u.email ILIKE $1 OR p.display_name ILIKE $1 OR p.phone ILIKE $1 OR u.phone ILIKE $1)
|
||||||
|
ORDER BY p.display_name NULLS LAST, u.email LIMIT $2`,
|
||||||
[`%${q.trim()}%`, limit],
|
[`%${q.trim()}%`, limit],
|
||||||
);
|
);
|
||||||
return reply.send({ items: result.rows });
|
return reply.send({ items: result.rows });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
'/pos/customers',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ['POS Terminal'],
|
||||||
|
summary: 'Create a customer from POS',
|
||||||
|
body: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['email'],
|
||||||
|
properties: {
|
||||||
|
email: { type: 'string', format: 'email' },
|
||||||
|
displayName: { type: 'string', maxLength: 160 },
|
||||||
|
phone: { type: 'string', maxLength: 40 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
response: { 401: errorSchema, 409: errorSchema },
|
||||||
|
} as FastifySchema,
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const user = await authenticate(request);
|
||||||
|
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
|
||||||
|
const body = parseJson(
|
||||||
|
z.object({
|
||||||
|
email: z.string().trim().email().transform((value) => value.toLowerCase()),
|
||||||
|
displayName: z.string().trim().min(1).max(160).optional(),
|
||||||
|
phone: z.string().trim().min(1).max(40).optional(),
|
||||||
|
}),
|
||||||
|
request.body ?? {},
|
||||||
|
);
|
||||||
|
const existing = await pool.query('SELECT id FROM identity_users WHERE lower(email) = $1', [body.email]);
|
||||||
|
if (existing.rows[0]) throw new AppError(409, 'CUSTOMER_EXISTS', 'Ya existe un cliente con ese email');
|
||||||
|
const { randomBytes } = await import('node:crypto');
|
||||||
|
const { Argon2PasswordHasher } = await import('../../identity/infrastructure/argon2-password-hasher.js');
|
||||||
|
const passwordHash = await new Argon2PasswordHasher().hash(randomBytes(32).toString('hex'));
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const created = await client.query<{ id: string; email: string }>(
|
||||||
|
`INSERT INTO identity_users (email, password_hash, role, phone)
|
||||||
|
VALUES ($1, $2, 'customer', $3) RETURNING id, email`,
|
||||||
|
[body.email, passwordHash, body.phone ?? null],
|
||||||
|
);
|
||||||
|
const customer = created.rows[0];
|
||||||
|
if (!customer) throw new AppError(500, 'CUSTOMER_CREATE_FAILED', 'No se pudo crear el cliente');
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO users_profiles (user_id, display_name, phone)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET display_name = EXCLUDED.display_name, phone = EXCLUDED.phone`,
|
||||||
|
[customer.id, body.displayName ?? null, body.phone ?? null],
|
||||||
|
);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return reply.code(201).send({ ...customer, displayName: body.displayName ?? null, phone: body.phone ?? null });
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
app.get<{ Params: { id: string } }>(
|
app.get<{ Params: { id: string } }>(
|
||||||
'/pos/customers/:id',
|
'/pos/customers/:id',
|
||||||
{
|
{
|
||||||
@@ -852,10 +914,10 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
|
|||||||
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
|
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT u.id, u.email, p.first_name AS "firstName", p.last_name AS "lastName", p.phone
|
`SELECT u.id, u.email, p.display_name AS "displayName", COALESCE(p.phone, u.phone) AS phone
|
||||||
FROM identity_users u
|
FROM identity_users u
|
||||||
LEFT JOIN users_profiles p ON p.user_id = u.id
|
LEFT JOIN users_profiles p ON p.user_id = u.id
|
||||||
WHERE u.id = $1 LIMIT 1`,
|
WHERE u.id = $1 AND u.role = 'customer' LIMIT 1`,
|
||||||
[id],
|
[id],
|
||||||
);
|
);
|
||||||
if (!result.rows[0]) throw new AppError(404, 'NOT_FOUND', 'Customer not found');
|
if (!result.rows[0]) throw new AppError(404, 'NOT_FOUND', 'Customer not found');
|
||||||
|
|||||||
3
work/artifacts/F-181/architect.md
Normal file
3
work/artifacts/F-181/architect.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# F-181
|
||||||
|
|
||||||
|
Use display_name/phone schema. Create identity customer transactionally with random Argon2 secret; expose search/create in register.
|
||||||
3
work/artifacts/F-181/documenter.md
Normal file
3
work/artifacts/F-181/documenter.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# F-181
|
||||||
|
|
||||||
|
TPV permite buscar por email, nombre o teléfono y crear/asociar clientes.
|
||||||
3
work/artifacts/F-181/implementer.md
Normal file
3
work/artifacts/F-181/implementer.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# F-181
|
||||||
|
|
||||||
|
Search aligned to display_name/profile/identity phone and customer role. Added secure random-Argon2 customer creation endpoint and search/create register tabs; creation associates customer immediately. Runtime create 201/search 200; test customer removed. POS build/typecheck pass.
|
||||||
1
work/artifacts/F-181/leader-close.json
Normal file
1
work/artifacts/F-181/leader-close.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"feature_id":"F-181","agent":"leader","stage":"close","verdict":"APPROVED","checks":[{"item":"all gates/runtime/verify","ok":true}],"issues":[]}
|
||||||
1
work/artifacts/F-181/qa.json
Normal file
1
work/artifacts/F-181/qa.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"feature_id":"F-181","agent":"qa","stage":"qa_gate","verdict":"APPROVED","checks":[{"item":"create 201","ok":true},{"item":"phone search 200","ok":true},{"item":"build/typecheck","ok":true}],"issues":[]}
|
||||||
1
work/artifacts/F-181/reviewer.json
Normal file
1
work/artifacts/F-181/reviewer.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"feature_id":"F-181","agent":"reviewer","stage":"review_gate","verdict":"APPROVED","checks":[{"item":"current profile schema","ok":true},{"item":"create and associate UI","ok":true}],"issues":[]}
|
||||||
1
work/artifacts/F-181/security.json
Normal file
1
work/artifacts/F-181/security.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"feature_id":"F-181","agent":"security","stage":"security_gate","verdict":"APPROVED","checks":[{"item":"staff RBAC","ok":true},{"item":"random Argon2 non-usable secret","ok":true},{"item":"transactional insert","ok":true}],"issues":[]}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
# F-180 — Decimal euro discounts
|
# F-181 — POS customer search and creation
|
||||||
|
|
||||||
Fixed discount input is a human EUR amount. Accept decimal comma or point and convert exactly once to integer cents. Percentage mode remains percentage.
|
Align customer query with current profile schema and add an authenticated cashier endpoint/form to create a customer with email, name and phone. Generated credentials are non-usable; customer can later use password reset.
|
||||||
|
|||||||
@@ -1,64 +1,64 @@
|
|||||||
{
|
{
|
||||||
"feature_id": "F-180",
|
"feature_id": "F-181",
|
||||||
"stage": "close",
|
"stage": "close",
|
||||||
"agent": "leader",
|
"agent": "leader",
|
||||||
"action": "close",
|
"action": "close",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"next_agent": "leader",
|
"next_agent": "leader",
|
||||||
"waiting_for": "Seleccionar una feature pending y actualizar este estado",
|
"waiting_for": "Seleccionar una feature pending y actualizar este estado",
|
||||||
"updated_at": "2026-08-22T17:07:28Z",
|
"updated_at": "2026-08-22T17:10:01Z",
|
||||||
"timeline": [
|
"timeline": [
|
||||||
{
|
{
|
||||||
"ts": "2026-08-22T17:06:25Z",
|
"ts": "2026-08-22T17:08:03Z",
|
||||||
"agent": "leader",
|
"agent": "leader",
|
||||||
"stage": "intake",
|
"stage": "intake",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "Accept decimal euro fixed discounts"
|
"message": "Repair POS customer lookup and creation"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ts": "2026-08-22T17:06:25Z",
|
"ts": "2026-08-22T17:08:03Z",
|
||||||
"agent": "architect",
|
"agent": "architect",
|
||||||
"stage": "design",
|
"stage": "design",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "design"
|
"message": "design"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ts": "2026-08-22T17:06:25Z",
|
"ts": "2026-08-22T17:08:03Z",
|
||||||
"agent": "implementer",
|
"agent": "implementer",
|
||||||
"stage": "build",
|
"stage": "build",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "Implement decimal EUR discounts"
|
"message": "Implement customer search/create"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ts": "2026-08-22T17:07:28Z",
|
"ts": "2026-08-22T17:10:01Z",
|
||||||
"agent": "reviewer",
|
"agent": "reviewer",
|
||||||
"stage": "review_gate",
|
"stage": "review_gate",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "review"
|
"message": "review"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ts": "2026-08-22T17:07:28Z",
|
"ts": "2026-08-22T17:10:01Z",
|
||||||
"agent": "security",
|
"agent": "security",
|
||||||
"stage": "security_gate",
|
"stage": "security_gate",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "security"
|
"message": "security"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ts": "2026-08-22T17:07:28Z",
|
"ts": "2026-08-22T17:10:01Z",
|
||||||
"agent": "qa",
|
"agent": "qa",
|
||||||
"stage": "qa_gate",
|
"stage": "qa_gate",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "qa"
|
"message": "qa"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ts": "2026-08-22T17:07:28Z",
|
"ts": "2026-08-22T17:10:01Z",
|
||||||
"agent": "documenter",
|
"agent": "documenter",
|
||||||
"stage": "document",
|
"stage": "document",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "document"
|
"message": "document"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ts": "2026-08-22T17:07:28Z",
|
"ts": "2026-08-22T17:10:01Z",
|
||||||
"agent": "leader",
|
"agent": "leader",
|
||||||
"stage": "close",
|
"stage": "close",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
|
|||||||
Reference in New Issue
Block a user