fix: finalize session after memory promotion
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "replay",
|
||||||
|
"runtimeExecutable": "node",
|
||||||
|
"runtimeArgs": ["tools/replay/server.mjs"],
|
||||||
|
"port": 5333
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -125,6 +125,31 @@ test("WIP icon toggles the Model activity panel and shows the streamed text", as
|
|||||||
expect(screen.getByText(/FIRSTLINE/)).toBeInTheDocument();
|
expect(screen.getByText(/FIRSTLINE/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("session finalization shows the completion banner and returns to landing", async () => {
|
||||||
|
useSessionStore.getState().resetSession();
|
||||||
|
let finalized = false;
|
||||||
|
server.use(
|
||||||
|
http.post("http://localhost:8787/sessions/:id/resume", () => new HttpResponse(null, { status: 204 })),
|
||||||
|
http.get("http://localhost:8787/sessions", () =>
|
||||||
|
HttpResponse.json(finalized ? [{ ...LIST[0], status: "finalized" }] : LIST)),
|
||||||
|
);
|
||||||
|
wrap();
|
||||||
|
await userEvent.click(await screen.findByText("Attiva uno"));
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
||||||
|
// The final turn ends: the session is now finalized on disk and agent_end
|
||||||
|
// arrives — the shell must refetch immediately (not wait for the 10s poll)
|
||||||
|
// and surface the new-session invite.
|
||||||
|
finalized = true;
|
||||||
|
act(() => {
|
||||||
|
useSessionStore.getState().applyEvent({ type: "system_event", event: "agent_end" } as any);
|
||||||
|
});
|
||||||
|
const cta = await screen.findByRole("button", { name: /start a new question/i });
|
||||||
|
expect(screen.getByText(/session completed and finalized/i)).toBeInTheDocument();
|
||||||
|
await userEvent.click(cta);
|
||||||
|
// Landing state: the composer invites a brand-new question.
|
||||||
|
expect(await screen.findByText(/type your question/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
test("renaming a group reassigns its members via setSessionGroup", async () => {
|
test("renaming a group reassigns its members via setSessionGroup", async () => {
|
||||||
const groupSets: Array<{ id: string; group: string }> = [];
|
const groupSets: Array<{ id: string; group: string }> = [];
|
||||||
server.use(
|
server.use(
|
||||||
|
|||||||
@@ -177,9 +177,13 @@ export function AppShell() {
|
|||||||
// stopSession already does, and this effect must stay side-effect-free on the
|
// stopSession already does, and this effect must stay side-effect-free on the
|
||||||
// backend if the session is already inactive.
|
// backend if the session is already inactive.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (lastSystemEvent?.type === "system_event" && (lastSystemEvent as any).event === "session_exit") {
|
if (lastSystemEvent?.type !== "system_event") return;
|
||||||
stopSession();
|
const ev = (lastSystemEvent as any).event;
|
||||||
}
|
if (ev === "session_exit") stopSession();
|
||||||
|
// The final workflow turn ends with the session already finalized on disk:
|
||||||
|
// refetch now instead of waiting for the 10s poll, so the completed state
|
||||||
|
// (and the new-session invite below the transcript) appears immediately.
|
||||||
|
if (ev === "agent_end") refresh();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [lastSystemEvent]);
|
}, [lastSystemEvent]);
|
||||||
|
|
||||||
@@ -241,6 +245,17 @@ export function AppShell() {
|
|||||||
<>
|
<>
|
||||||
<CentralStatus working={working} />
|
<CentralStatus working={working} />
|
||||||
<WidgetHost sessionId={activeSessionId} />
|
<WidgetHost sessionId={activeSessionId} />
|
||||||
|
{finalized && !agentActive && (
|
||||||
|
<div className="rounded-2xl border border-border/80 bg-card p-5 text-center shadow-md">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Session completed and finalized — the SQL and all phase
|
||||||
|
documents are saved.
|
||||||
|
</p>
|
||||||
|
<Button className="mt-3" onClick={startNewSession}>
|
||||||
|
Start a new question
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState />
|
<EmptyState />
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { createFakePi } = require("./fake_pi_runtime.js");
|
||||||
|
|
||||||
|
// F8 auto-close: reviewer_memory_promote is the last human interaction of the
|
||||||
|
// workflow — after recording the promotion the gate must advance F8 and finalize
|
||||||
|
// the session itself (no trailing reviewer_confirm "approve" form). These tests
|
||||||
|
// drive the real tool execute against a fake `tht` CLI placed on PATH.
|
||||||
|
|
||||||
|
// tht-gate.js calls a bare `require` (Pi's runtime provides it); under node --test
|
||||||
|
// the ESM import has none, so expose the CJS require globally for the gate module.
|
||||||
|
globalThis.require = require;
|
||||||
|
|
||||||
|
const installGatePromise = import("../../tht-gate.js").then((m) => m.default);
|
||||||
|
|
||||||
|
function setupFakeTht(t, { phase, status = "open" }) {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fake-tht-"));
|
||||||
|
const log = path.join(dir, "calls.log");
|
||||||
|
fs.writeFileSync(log, "");
|
||||||
|
const script = `#!/bin/bash
|
||||||
|
echo "$@" >> "${log}"
|
||||||
|
case "$1 $2" in
|
||||||
|
"phase show") echo "Fase corrente: ${phase}";;
|
||||||
|
"phase meta") echo '{"max_phase":8,"phases":[{"num":2,"id":"F2","emits":[]},{"num":8,"id":"F8","emits":[]}]}';;
|
||||||
|
"memory promote") echo "[]";;
|
||||||
|
"session show") echo '{"status":"${status}"}';;
|
||||||
|
*) echo "OK";;
|
||||||
|
esac
|
||||||
|
`;
|
||||||
|
const bin = path.join(dir, "tht");
|
||||||
|
fs.writeFileSync(bin, script, { mode: 0o755 });
|
||||||
|
const oldPath = process.env.PATH;
|
||||||
|
process.env.PATH = `${dir}:${oldPath}`;
|
||||||
|
t.after(() => {
|
||||||
|
process.env.PATH = oldPath;
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
return { dir, calls: () => fs.readFileSync(log, "utf8") };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPromote(t, { phase }) {
|
||||||
|
const fake = setupFakeTht(t, { phase });
|
||||||
|
const installGate = await installGatePromise;
|
||||||
|
const { pi, ctx, tools } = createFakePi();
|
||||||
|
ctx.cwd = fake.dir; // execFileSync needs an existing cwd; no .env here is fine
|
||||||
|
installGate(pi);
|
||||||
|
const { def } = tools.get("reviewer_memory_promote");
|
||||||
|
const res = await def.execute("t1", { session: "s1" }, null, null, ctx);
|
||||||
|
return { res, calls: fake.calls() };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("F8 + zero candidati: il gate avanza la fase e finalizza da solo", async (t) => {
|
||||||
|
const { res, calls } = await runPromote(t, { phase: 8 });
|
||||||
|
const text = res.content[0].text;
|
||||||
|
assert.match(text, /sessione finalizzata \(s1\)/);
|
||||||
|
assert.match(text, /NON presentare altri gate/);
|
||||||
|
assert.match(calls, /^phase advance --session s1$/m);
|
||||||
|
assert.match(calls, /^session finalize s1$/m);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fuori dall'ultima fase non chiude nulla (comportamento precedente)", async (t) => {
|
||||||
|
const { res, calls } = await runPromote(t, { phase: 2 });
|
||||||
|
assert.match(res.content[0].text, /prosegui con la chiusura della sessione/);
|
||||||
|
assert.doesNotMatch(calls, /phase advance/);
|
||||||
|
assert.doesNotMatch(calls, /session finalize/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("phase-gate su sessione gia' finalizzata: nessun widget, il modello viene fermato", async (t) => {
|
||||||
|
setupFakeTht(t, { phase: 8, status: "finalized" });
|
||||||
|
const installGate = await installGatePromise;
|
||||||
|
const { pi, ctx, tools } = createFakePi();
|
||||||
|
ctx.cwd = os.tmpdir();
|
||||||
|
installGate(pi);
|
||||||
|
const { def } = tools.get("reviewer_confirm");
|
||||||
|
const res = await def.execute(
|
||||||
|
"t2",
|
||||||
|
{ session: "s1", kind: "phase", title: "F8", artifact: { kind: "phase", data: {} } },
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
assert.match(res.content[0].text, /gia' finalizzata/);
|
||||||
|
assert.equal(ctx.uiCalls.length, 0); // la form "approve" NON viene ripresentata
|
||||||
|
});
|
||||||
@@ -832,6 +832,24 @@ export default function (pi) {
|
|||||||
const curNum = currentPhase(ctx, session);
|
const curNum = currentPhase(ctx, session);
|
||||||
const phase = phaseId(ctx, curNum);
|
const phase = phaseId(ctx, curNum);
|
||||||
|
|
||||||
|
// Sessione gia' finalizzata: non c'e' piu' nulla da approvare — mai
|
||||||
|
// ripresentare il phase-gate (la form "approve") a workflow chiuso.
|
||||||
|
if (kind === "phase" && curNum >= phaseMeta(ctx).max_phase) {
|
||||||
|
try {
|
||||||
|
const show = JSON.parse(tht(ctx, ["session", "show", session, "--json"]));
|
||||||
|
if (show?.status === "finalized") {
|
||||||
|
lockActive = false;
|
||||||
|
return textResult(
|
||||||
|
`La sessione ${session} e' gia' finalizzata: il workflow e' completo. ` +
|
||||||
|
"Comunica al reviewer il riepilogo finale e termina il turno; NON " +
|
||||||
|
"presentare altri gate reviewer_*.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// show non leggibile: prosegui col gate normale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- v2 payload construction (WS2) -------------------------------------
|
// --- v2 payload construction (WS2) -------------------------------------
|
||||||
// The gate builds/validates the structured payload BEFORE showing the
|
// The gate builds/validates the structured payload BEFORE showing the
|
||||||
// widget, so the reviewer approves a deterministic artifact (not the raw
|
// widget, so the reviewer approves a deterministic artifact (not the raw
|
||||||
@@ -948,26 +966,9 @@ export default function (pi) {
|
|||||||
return textResult("Il reviewer vuole uscire.");
|
return textResult("Il reviewer vuole uscire.");
|
||||||
// outcome.kind === "approve" -> execute the privileged action via the CLI.
|
// outcome.kind === "approve" -> execute the privileged action via the CLI.
|
||||||
if (kind === "phase") {
|
if (kind === "phase") {
|
||||||
// Explicit human approval: advance unconditionally except for unmet
|
const r = advancePhaseAndFinalize(ctx, session, curNum);
|
||||||
// prerequisites. Plain `phase advance` (no --auto) enforces advance_problems
|
if (r.err) return r.err;
|
||||||
// and exits 6 with the missing items, which relayIfThtFails surfaces.
|
if (r.finalized) {
|
||||||
const err = relayIfThtFails(
|
|
||||||
ctx,
|
|
||||||
["phase", "advance", "--session", session],
|
|
||||||
PHASE_RECOVERY,
|
|
||||||
);
|
|
||||||
if (err) return err;
|
|
||||||
// Auto-finalize after advancing the LAST phase: the model may not
|
|
||||||
// follow through (token budget, turn end) leaving the session open.
|
|
||||||
const meta = phaseMeta(ctx);
|
|
||||||
if (curNum >= meta.max_phase) {
|
|
||||||
const fErr = relayIfThtFails(
|
|
||||||
ctx,
|
|
||||||
["session", "finalize", session],
|
|
||||||
"Fase approvata ma finalizzazione fallita: esegui manualmente " +
|
|
||||||
`\`tht session finalize ${session}\`.`,
|
|
||||||
);
|
|
||||||
if (fErr) return fErr;
|
|
||||||
lockActive = false;
|
lockActive = false;
|
||||||
lastSteered = false;
|
lastSteered = false;
|
||||||
return textResult(
|
return textResult(
|
||||||
@@ -1053,6 +1054,46 @@ export default function (pi) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Explicit human approval: advance unconditionally except for unmet
|
||||||
|
// prerequisites. Plain `phase advance` (no --auto) enforces advance_problems
|
||||||
|
// and exits 6 with the missing items, which relayIfThtFails surfaces.
|
||||||
|
// Auto-finalize after advancing the LAST phase: the model may not follow
|
||||||
|
// through (token budget, turn end) leaving the session open.
|
||||||
|
function advancePhaseAndFinalize(ctx, session, curNum) {
|
||||||
|
const err = relayIfThtFails(
|
||||||
|
ctx,
|
||||||
|
["phase", "advance", "--session", session],
|
||||||
|
PHASE_RECOVERY,
|
||||||
|
);
|
||||||
|
if (err) return { err };
|
||||||
|
if (curNum < phaseMeta(ctx).max_phase) return { finalized: false };
|
||||||
|
const fErr = relayIfThtFails(
|
||||||
|
ctx,
|
||||||
|
["session", "finalize", session],
|
||||||
|
"Fase approvata ma finalizzazione fallita: esegui manualmente " +
|
||||||
|
`\`tht session finalize ${session}\`.`,
|
||||||
|
);
|
||||||
|
if (fErr) return { err: fErr };
|
||||||
|
return { finalized: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// La promozione memorie e' l'ultima interazione umana di F8: chiudere qui
|
||||||
|
// (advance + finalize) evita il reviewer_confirm finale a workflow gia'
|
||||||
|
// deciso (la form "approve" ridondante a fine sessione). Fuori dall'ultima
|
||||||
|
// fase ritorna null e il chiamante mantiene il comportamento precedente.
|
||||||
|
function closeAfterPromotion(ctx, session, curNum, summary) {
|
||||||
|
if (curNum < phaseMeta(ctx).max_phase) return null;
|
||||||
|
const r = advancePhaseAndFinalize(ctx, session, curNum);
|
||||||
|
if (r.err) return r.err;
|
||||||
|
lockActive = false;
|
||||||
|
lastSteered = false;
|
||||||
|
return textResult(
|
||||||
|
`${summary} Fase ${curNum} approvata e sessione finalizzata (${session}): ` +
|
||||||
|
"il workflow e' completo. Comunica al reviewer il riepilogo finale " +
|
||||||
|
"della sessione e termina il turno; NON presentare altri gate reviewer_*.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
pi.registerTool({
|
pi.registerTool({
|
||||||
name: "reviewer_memory_promote",
|
name: "reviewer_memory_promote",
|
||||||
label: "Promozione memorie riusabili (reviewer)",
|
label: "Promozione memorie riusabili (reviewer)",
|
||||||
@@ -1062,14 +1103,17 @@ export default function (pi) {
|
|||||||
"table_promoted/table_excluded, max 5, esclusi i gia' promossi/rifiutati). Le selezioni " +
|
"table_promoted/table_excluded, max 5, esclusi i gia' promossi/rifiutati). Le selezioni " +
|
||||||
"vengono salvate nel vectordb (tht memory save-one) e registrate come memory_promoted; " +
|
"vengono salvate nel vectordb (tht memory save-one) e registrate come memory_promoted; " +
|
||||||
"le deselezioni come memory_promotion_declined (non riproposte). Nessun parametro oltre " +
|
"le deselezioni come memory_promotion_declined (non riproposte). Nessun parametro oltre " +
|
||||||
"alla sessione: i candidati sono deterministici, NON li scrivi tu.",
|
"alla sessione: i candidati sono deterministici, NON li scrivi tu. Registrata la " +
|
||||||
|
"promozione, in F8 il gate chiude la fase e finalizza la sessione da solo: NON " +
|
||||||
|
"presentare un reviewer_confirm dopo.",
|
||||||
parameters: Type.Object({
|
parameters: Type.Object({
|
||||||
session: Type.String(),
|
session: Type.String(),
|
||||||
}),
|
}),
|
||||||
async execute(_id, params, _signal, _onUpdate, ctx) {
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
||||||
lockActive = true;
|
lockActive = true;
|
||||||
const { session } = params;
|
const { session } = params;
|
||||||
const phase = phaseId(ctx, currentPhase(ctx, session));
|
const curNum = currentPhase(ctx, session);
|
||||||
|
const phase = phaseId(ctx, curNum);
|
||||||
let candidates;
|
let candidates;
|
||||||
try {
|
try {
|
||||||
candidates = JSON.parse(
|
candidates = JSON.parse(
|
||||||
@@ -1084,6 +1128,10 @@ export default function (pi) {
|
|||||||
"Nessuna decisione riusabile da promuovere in memoria per questa sessione.",
|
"Nessuna decisione riusabile da promuovere in memoria per questa sessione.",
|
||||||
"info",
|
"info",
|
||||||
);
|
);
|
||||||
|
const closed = closeAfterPromotion(
|
||||||
|
ctx, session, curNum, "Nessun candidato di promozione.",
|
||||||
|
);
|
||||||
|
if (closed) return closed;
|
||||||
return textResult(
|
return textResult(
|
||||||
"Nessun candidato di promozione: prosegui con la chiusura della sessione.",
|
"Nessun candidato di promozione: prosegui con la chiusura della sessione.",
|
||||||
);
|
);
|
||||||
@@ -1128,10 +1176,12 @@ export default function (pi) {
|
|||||||
}), "");
|
}), "");
|
||||||
if (err) return err;
|
if (err) return err;
|
||||||
}
|
}
|
||||||
return textResult(
|
const summary =
|
||||||
`Promozione registrata: ${saved} memorie salvate nel vectordb, ` +
|
`Promozione registrata: ${saved} memorie salvate nel vectordb, ` +
|
||||||
`${decline.length} candidati scartati.`,
|
`${decline.length} candidati scartati.`;
|
||||||
);
|
const closed = closeAfterPromotion(ctx, session, curNum, summary);
|
||||||
|
if (closed) return closed;
|
||||||
|
return textResult(summary);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ substantive decisions.
|
|||||||
| F5 sintesi | — | `reviewer_confirm kind:"phase"` (after `tht session check`) |
|
| F5 sintesi | — | `reviewer_confirm kind:"phase"` (after `tht session check`) |
|
||||||
| F6 cte | `cte_plan.json`, `ctes/`, `cte_tests.json` | approve each CTE `kind:"cte_result"`, then `reviewer_confirm kind:"phase"` |
|
| F6 cte | `cte_plan.json`, `ctes/`, `cte_tests.json` | approve each CTE `kind:"cte_result"`, then `reviewer_confirm kind:"phase"` |
|
||||||
| F7 sql_finale | `sql_final.sql` | `kind:"sql"` records `sql_approved`, then `reviewer_confirm kind:"phase"` |
|
| F7 sql_finale | `sql_final.sql` | `kind:"sql"` records `sql_approved`, then `reviewer_confirm kind:"phase"` |
|
||||||
| F8 datamart | — | `reviewer_confirm kind:"phase"` |
|
| F8 datamart | — | auto: the `reviewer_memory_promote` gate advances F8 and finalizes the session itself (`reviewer_confirm kind:"phase"` only as fallback if it reports an error) |
|
||||||
|
|
||||||
## Disciplines (hold in every phase)
|
## Disciplines (hold in every phase)
|
||||||
|
|
||||||
@@ -387,19 +387,24 @@ Prerequisite: Phase 7 closed.
|
|||||||
1. Ask the reviewer whether they want a datamart (`reviewer_select` yes/no).
|
1. Ask the reviewer whether they want a datamart (`reviewer_select` yes/no).
|
||||||
2. If yes: `tht datamart generate` (stub — raises NotImplementedError for now). Tell
|
2. If yes: `tht datamart generate` (stub — raises NotImplementedError for now). Tell
|
||||||
the reviewer that dbt generation is not implemented yet.
|
the reviewer that dbt generation is not implemented yet.
|
||||||
3. **Memory promotion.** Call `reviewer_memory_promote` with ONLY the session id: the
|
3. **Memory promotion closes the session.** Call `reviewer_memory_promote` with ONLY
|
||||||
gate computes the candidates itself (`tht memory promote --preview` — the 3
|
the session id: the gate computes the candidates itself (`tht memory promote
|
||||||
reusable types, already excluding promoted/declined ones) and shows the reviewer a
|
--preview` — the 3 reusable types, already excluding promoted/declined ones) and
|
||||||
pre-selected checklist. Selected → saved to the vectordb + `memory_promoted`;
|
shows the reviewer a pre-selected checklist. Selected → saved to the vectordb +
|
||||||
deselected → `memory_promotion_declined` (never re-proposed). If the gate reports
|
`memory_promoted`; deselected → `memory_promotion_declined` (never re-proposed).
|
||||||
zero candidates, move on — do not retry.
|
After recording the promotion (even with zero candidates) the gate advances F8 and
|
||||||
4. Close with `reviewer_confirm kind:"phase"`. The gate auto-finalizes the session
|
finalizes the session itself — do NOT present a `reviewer_confirm kind:"phase"`
|
||||||
after advancing the last phase — you do NOT need to call `tht session finalize`
|
afterwards: there is nothing left to approve. When the gate answers "sessione
|
||||||
yourself. If auto-finalize fails, the error message tells you the recovery command.
|
finalizzata", give the reviewer the final summary and end the turn.
|
||||||
|
4. If the gate reports an error instead (e.g. the datamart decision is missing),
|
||||||
|
fix the prerequisite and call `reviewer_memory_promote` again. Only if the gate
|
||||||
|
says the session is still open, close with `reviewer_confirm kind:"phase"` as a
|
||||||
|
fallback — it auto-finalizes after advancing the last phase too.
|
||||||
|
|
||||||
## Session end
|
## Session end
|
||||||
|
|
||||||
When Phase 8 is approved, the gate calls `tht session finalize` automatically.
|
When the promotion gate (or, as fallback, the F8 phase gate) closes Phase 8, the
|
||||||
|
gate calls `tht session finalize` automatically.
|
||||||
Finalize also indexes the question→SQL pair in the vectordb (kind `solved_question`,
|
Finalize also indexes the question→SQL pair in the vectordb (kind `solved_question`,
|
||||||
best-effort — on failure recover with `tht memory solved-index <id>`). The persisted
|
best-effort — on failure recover with `tht memory solved-index <id>`). The persisted
|
||||||
state (ledger `review_decisions.jsonl` + artifacts) is the truth: what is not
|
state (ledger `review_decisions.jsonl` + artifacts) is the truth: what is not
|
||||||
|
|||||||
Reference in New Issue
Block a user