fix: close PI-140 cutover gaps
Back up unknown legacy Agents, reconnect settled Conversation observation, remove remaining active repository residue, and restore the bundled Python verifier import.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,7 +2,6 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
.pnpm-store/
|
.pnpm-store/
|
||||||
.pnp.*
|
.pnp.*
|
||||||
.opencode/node_modules/
|
|
||||||
|
|
||||||
# Build and packaged output
|
# Build and packaged output
|
||||||
build/
|
build/
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
- Worktree: D:\Datas\OthersProjects\makelore-pi-remove-opencode-a41c9e72
|
- Worktree: D:\Datas\OthersProjects\makelore-pi-remove-opencode-a41c9e72
|
||||||
- Base commit: 61fede2b4dfed3e29a6c4e37aee73896713979e1
|
- Base commit: 61fede2b4dfed3e29a6c4e37aee73896713979e1
|
||||||
- Owner: codex-root
|
- Owner: codex-root
|
||||||
- Status: Implementation Complete; Planner Review Pending
|
- Status: Needs Fix Addressed; Planner Re-review Pending
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -77,9 +77,17 @@
|
|||||||
bundle/smoke scripts, dependencies, and obsolete tests were removed.
|
bundle/smoke scripts, dependencies, and obsolete tests were removed.
|
||||||
- Retained only the migration-owned schema-v1 normalizer and expected generated
|
- Retained only the migration-owned schema-v1 normalizer and expected generated
|
||||||
Agent comparison in `electron/coding-projects/legacy-v1.ts`. Migration keeps
|
Agent comparison in `electron/coding-projects/legacy-v1.ts`. Migration keeps
|
||||||
exact-delete/backup/rollback/notice behavior, valid schema-v2 projects bypass
|
exact-delete/backup/rollback/notice behavior: it enumerates the legacy Agent
|
||||||
legacy reads, unrelated `.opencode` content remains inert, and no `.pi/agents`
|
directory, removes exact generated files, and moves modified plus unknown Agent
|
||||||
directory is introduced.
|
files into the timestamped backup. Valid schema-v2 projects bypass legacy reads,
|
||||||
|
unrelated `.opencode` content remains inert, and no `.pi/agents` directory is
|
||||||
|
introduced.
|
||||||
|
- Preserved the existing session-observation product behavior at the
|
||||||
|
vendor-neutral boundary. A live ordinary prompt that reaches the authoritative
|
||||||
|
completed settlement queues the final Coding Conversation Snapshot using the
|
||||||
|
project id and product Conversation id; hydrate/reconnect snapshots,
|
||||||
|
steer/follow-up, failed/aborted turns, and replies without assistant natural
|
||||||
|
language do not enqueue.
|
||||||
- Preserved compatibility identifiers `app.niancode.desktop`, `niancode`, and
|
- Preserved compatibility identifiers `app.niancode.desktop`, `niancode`, and
|
||||||
the persisted electron-store name `opencode-projects`. The remaining source
|
the persisted electron-store name `opencode-projects`. The remaining source
|
||||||
references to OpenCode are categorized compatibility/history boundaries:
|
references to OpenCode are categorized compatibility/history boundaries:
|
||||||
@@ -99,6 +107,10 @@
|
|||||||
for its Provider protocol adapters. They are part of Pi's Provider package,
|
for its Provider protocol adapters. They are part of Pi's Provider package,
|
||||||
not the removed OpenCode runtime/package/plugin/fallback, and remain covered by
|
not the removed OpenCode runtime/package/plugin/fallback, and remain covered by
|
||||||
the explicitly accepted real-Provider qualification risk.
|
the explicitly accepted real-Provider qualification risk.
|
||||||
|
- Removed the unreferenced legacy session run machine and old `.gitignore`
|
||||||
|
exception, updated current repository guidance and the session-observation
|
||||||
|
integration guide to Coding/Pi, and restored the `node:path` import required by
|
||||||
|
the bundled-Python verifier.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
@@ -108,13 +120,19 @@
|
|||||||
noise remains.
|
noise remains.
|
||||||
- `pnpm run typecheck`: passed, including after the pinned-pnpm dependency rebuild.
|
- `pnpm run typecheck`: passed, including after the pinned-pnpm dependency rebuild.
|
||||||
- `pnpm exec eslint . --quiet`: passed.
|
- `pnpm exec eslint . --quiet`: passed.
|
||||||
- Full unit suite: 174 files passed; 1491 tests passed, 2 skipped (1493 total).
|
- Full unit suite after planner fixes: 174 files passed; 1495 tests passed,
|
||||||
|
2 skipped (1497 total).
|
||||||
- Pressure suite: 1 file and 1 test passed.
|
- Pressure suite: 1 file and 1 test passed.
|
||||||
- Focused migration/resource suite: 2 files and 9 tests passed, including the
|
- Focused post-review suite: 3 files and 33 tests passed for migration,
|
||||||
valid-v2 no-legacy-read/no-generated-Agent-delete case.
|
session-observation sync, and Conversation store settlement behavior. It covers
|
||||||
|
valid-v2 no-legacy-read, unknown/modified Agent backup, live ordinary-prompt
|
||||||
|
settlement, no hydrate/follow-up replay, and assistant-natural-language gating.
|
||||||
- Electron E2E: 3 tests passed for first Pi conversation latency/submit ordering,
|
- Electron E2E: 3 tests passed for first Pi conversation latency/submit ordering,
|
||||||
Pi product UI, and bundled Skill project configuration.
|
Pi product UI, and bundled Skill project configuration.
|
||||||
- `pnpm run build:vite`: passed (Renderer, Electron Main, Preload, and utility).
|
- `pnpm run build:vite`: passed (Renderer, Electron Main, Preload, and utility).
|
||||||
|
- `node scripts/verify-bundled-python.mjs --root resources/python/win32-x64
|
||||||
|
--target win32-x64`: passed for Python 3.12.13 and stdlib after restoring the
|
||||||
|
required `node:path` import.
|
||||||
- Windows Pi bundle: passed with 130 production packages and 6 runtime assets.
|
- Windows Pi bundle: passed with 130 production packages and 6 runtime assets.
|
||||||
- Staged Pi probe: local sessions, shell, abort, and concurrency passed; decision
|
- Staged Pi probe: local sessions, shell, abort, and concurrency passed; decision
|
||||||
intentionally remained `partial-pass` / `incomplete` because real Provider
|
intentionally remained `partial-pass` / `incomplete` because real Provider
|
||||||
@@ -128,10 +146,18 @@
|
|||||||
- Product-package Pi probe: local surface passed; cold p95 638 ms and warm p95
|
- Product-package Pi probe: local surface passed; cold p95 638 ms and warm p95
|
||||||
626 ms. Decision intentionally remained `partial-pass` / `incomplete` because
|
626 ms. Decision intentionally remained `partial-pass` / `incomplete` because
|
||||||
the Provider matrix has `realTurnVerified: false`.
|
the Provider matrix has `realTurnVerified: false`.
|
||||||
- Zero-residue scans passed for source/package metadata, `/api/opencode`, Windows
|
- Repository-wide zero-residue scan has no unclassified hit. Production hits are
|
||||||
artifact paths, ASAR package names, and old runtime resource roots. The scan
|
limited to migration paths, persisted compatibility identifiers, packaging
|
||||||
separately categorized the upstream Pi Provider adapter filenames described
|
exclusion, and the one-time notice; tests contain migration fixtures and
|
||||||
in Outcome rather than treating their text as a legacy runtime residue.
|
negative assertions; old task/evidence records remain history. Canonical
|
||||||
|
project-memory descriptions are explicitly listed below for PI-160 promotion,
|
||||||
|
as required by feature-worktree ownership. The Windows artifact/ASAR scan also
|
||||||
|
separately categorizes the upstream Pi Provider adapter filenames described in
|
||||||
|
Outcome rather than treating their text as a legacy runtime residue.
|
||||||
|
- Planner review of `61fede2..5a275b9` returned Standards/Spec Needs Fix for four
|
||||||
|
P1s: unknown Agent backup, silently disconnected session observation, repo-wide
|
||||||
|
residue, and the Python verifier import. The current post-review changes address
|
||||||
|
all four and await incremental planner re-review.
|
||||||
- `git diff --check`: passed; only the repository's existing LF-to-CRLF warnings
|
- `git diff --check`: passed; only the repository's existing LF-to-CRLF warnings
|
||||||
were emitted.
|
were emitted.
|
||||||
- macOS x64/arm64 validation was explicitly skipped and is not reported as Pass.
|
- macOS x64/arm64 validation was explicitly skipped and is not reported as Pass.
|
||||||
|
|||||||
@@ -34,9 +34,10 @@ Use the exact pnpm version pinned by `packageManager` in `package.json`.
|
|||||||
## Agent and project configuration
|
## Agent and project configuration
|
||||||
|
|
||||||
- Project configuration is authoritative in `.niancode/project.json`.
|
- Project configuration is authoritative in `.niancode/project.json`.
|
||||||
- Agent definitions and generated `.opencode/agent/` files are project-owned.
|
- Agent definitions are project-owned in `.niancode/project.json`; Pi runtime
|
||||||
|
state must not become an alternative source of project configuration.
|
||||||
- Stable Agent ids preserve sessions; display names are editable.
|
- Stable Agent ids preserve sessions; display names are editable.
|
||||||
- Do not refactor Agent templates, prompts, or OpenCode structure as an incidental part of unrelated work.
|
- Do not refactor Agent templates, prompts, or the Coding/Pi structure as an incidental part of unrelated work.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
copyFile,
|
copyFile,
|
||||||
mkdir,
|
mkdir,
|
||||||
|
readdir,
|
||||||
readFile,
|
readFile,
|
||||||
rm,
|
rm,
|
||||||
unlink,
|
unlink,
|
||||||
} from 'node:fs/promises';
|
} from 'node:fs/promises';
|
||||||
|
import type { Dirent } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import type { ProductModelRef } from '../coding-runtime/contracts';
|
import type { ProductModelRef } from '../coding-runtime/contracts';
|
||||||
import {
|
import {
|
||||||
@@ -93,12 +95,22 @@ async function snapshotLegacyAgentFiles(
|
|||||||
path.basename(entry.relativePath),
|
path.basename(entry.relativePath),
|
||||||
entry.content,
|
entry.content,
|
||||||
]));
|
]));
|
||||||
|
let entries: Dirent[];
|
||||||
|
try {
|
||||||
|
entries = await readdir(agentDirectory, { withFileTypes: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissing(error)) return [];
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
const snapshots: AgentFileSnapshot[] = [];
|
const snapshots: AgentFileSnapshot[] = [];
|
||||||
for (const [fileName, expectedContent] of expected) {
|
for (const entry of entries
|
||||||
|
.filter((candidate) => candidate.isFile())
|
||||||
|
.sort((left, right) => left.name.localeCompare(right.name))) {
|
||||||
|
const fileName = entry.name;
|
||||||
const filePath = path.join(agentDirectory, fileName);
|
const filePath = path.join(agentDirectory, fileName);
|
||||||
const content = await readOptionalText(filePath);
|
const content = await readOptionalText(filePath);
|
||||||
if (content === null) continue;
|
if (content === null) continue;
|
||||||
const generated = expectedContent === content;
|
const generated = expected.get(fileName) === content;
|
||||||
if (generated) {
|
if (generated) {
|
||||||
snapshots.push({ fileName, filePath, content, generated });
|
snapshots.push({ fileName, filePath, content, generated });
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import path from 'node:path';
|
||||||
import { getPythonTarget, PYTHON_VERSION } from './bundled-python-manifest.mjs';
|
import { getPythonTarget, PYTHON_VERSION } from './bundled-python-manifest.mjs';
|
||||||
|
|
||||||
function readOption(name) {
|
function readOption(name) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
useUserProfileStore,
|
useUserProfileStore,
|
||||||
} from '@/stores/user-profile';
|
} from '@/stores/user-profile';
|
||||||
import type { RawMessage } from '@/types/chat';
|
import type { RawMessage } from '@/types/chat';
|
||||||
|
import type { ConversationSnapshot } from '@/types/coding-conversation';
|
||||||
import type {
|
import type {
|
||||||
AgentSessionData,
|
AgentSessionData,
|
||||||
AgentSessionMessage,
|
AgentSessionMessage,
|
||||||
@@ -338,14 +339,7 @@ async function flushUpload(key: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Queue a completed local session snapshot without blocking the chat turn. */
|
function queueAgentSessionData(data: AgentSessionData): void {
|
||||||
export function queueAgentSessionSync(
|
|
||||||
projectId: string,
|
|
||||||
sessionId: string,
|
|
||||||
messages: readonly RawMessage[],
|
|
||||||
): void {
|
|
||||||
const data = buildAgentSessionData(projectId, sessionId, messages);
|
|
||||||
if (!data) return;
|
|
||||||
const userId = getCurrentAccountKey();
|
const userId = getCurrentAccountKey();
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
|
|
||||||
@@ -356,6 +350,44 @@ export function queueAgentSessionSync(
|
|||||||
scheduleUpload(key, SESSION_UPLOAD_DEBOUNCE_MS);
|
scheduleUpload(key, SESSION_UPLOAD_DEBOUNCE_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Queue a completed local session snapshot without blocking the chat turn. */
|
||||||
|
export function queueAgentSessionSync(
|
||||||
|
projectId: string,
|
||||||
|
sessionId: string,
|
||||||
|
messages: readonly RawMessage[],
|
||||||
|
): void {
|
||||||
|
const data = buildAgentSessionData(projectId, sessionId, messages);
|
||||||
|
if (!data) return;
|
||||||
|
queueAgentSessionData(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Queue the final product Conversation snapshot after a completed prompt turn. */
|
||||||
|
export function queueCodingConversationSessionSync(snapshot: ConversationSnapshot): void {
|
||||||
|
const messages: RawMessage[] = snapshot.nodes.flatMap((node) => {
|
||||||
|
if (node.kind !== 'message') return [];
|
||||||
|
return [{
|
||||||
|
id: node.id,
|
||||||
|
role: node.role,
|
||||||
|
content: node.blocks.flatMap((block) => (
|
||||||
|
block.kind === 'text' ? [{ type: 'text', text: block.text }] : []
|
||||||
|
)),
|
||||||
|
isError: node.status === 'error' || node.status === 'aborted',
|
||||||
|
} satisfies RawMessage];
|
||||||
|
});
|
||||||
|
const settledAt = snapshot.run.settledAt;
|
||||||
|
const updatedAt = typeof settledAt === 'number' && Number.isFinite(settledAt)
|
||||||
|
? new Date(settledAt).toISOString()
|
||||||
|
: new Date().toISOString();
|
||||||
|
const data = buildAgentSessionData(
|
||||||
|
snapshot.conversation.projectId,
|
||||||
|
snapshot.conversation.id,
|
||||||
|
messages,
|
||||||
|
updatedAt,
|
||||||
|
);
|
||||||
|
if (!data?.messages.some((message) => message.role === 'assistant')) return;
|
||||||
|
queueAgentSessionData(data);
|
||||||
|
}
|
||||||
|
|
||||||
/** Flush persisted session snapshots after login or profile bootstrap. */
|
/** Flush persisted session snapshots after login or profile bootstrap. */
|
||||||
export async function flushPendingAgentSessionSync(): Promise<void> {
|
export async function flushPendingAgentSessionSync(): Promise<void> {
|
||||||
hydratePendingUploads();
|
hydratePendingUploads();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
type ConversationReducerState,
|
type ConversationReducerState,
|
||||||
} from '../../shared/coding-conversation-reducer';
|
} from '../../shared/coding-conversation-reducer';
|
||||||
import { AppError } from '@/lib/error-model';
|
import { AppError } from '@/lib/error-model';
|
||||||
|
import { queueCodingConversationSessionSync } from '@/lib/agent-session-sync';
|
||||||
import {
|
import {
|
||||||
getCodingConversationSnapshot,
|
getCodingConversationSnapshot,
|
||||||
openCodingConversationEvents,
|
openCodingConversationEvents,
|
||||||
@@ -55,6 +56,7 @@ interface CodingConversationStoreDependencies {
|
|||||||
openEvents(conversationId?: string): Promise<EventSource>;
|
openEvents(conversationId?: string): Promise<EventSource>;
|
||||||
submitPrompt(input: SubmitCodingConversationPromptInput): Promise<PromptAcceptance>;
|
submitPrompt(input: SubmitCodingConversationPromptInput): Promise<PromptAcceptance>;
|
||||||
recover(conversationId: string): Promise<void>;
|
recover(conversationId: string): Promise<void>;
|
||||||
|
queueSettledSessionSync(snapshot: ConversationSnapshot): void;
|
||||||
createId(kind: 'request' | 'node'): string;
|
createId(kind: 'request' | 'node'): string;
|
||||||
preparationTimeoutMs: number;
|
preparationTimeoutMs: number;
|
||||||
}
|
}
|
||||||
@@ -277,6 +279,7 @@ function defaultDependencies(): CodingConversationStoreDependencies {
|
|||||||
openEvents: openCodingConversationEvents,
|
openEvents: openCodingConversationEvents,
|
||||||
submitPrompt: submitCodingConversationPrompt,
|
submitPrompt: submitCodingConversationPrompt,
|
||||||
recover: recoverCodingConversation,
|
recover: recoverCodingConversation,
|
||||||
|
queueSettledSessionSync: queueCodingConversationSessionSync,
|
||||||
createId: () => crypto.randomUUID(),
|
createId: () => crypto.randomUUID(),
|
||||||
preparationTimeoutMs: 10_000,
|
preparationTimeoutMs: 10_000,
|
||||||
};
|
};
|
||||||
@@ -332,6 +335,26 @@ function validPatchBatchRange(event: CodingConversationPatchBatchEvent): boolean
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function completesOrdinaryPrompt(
|
||||||
|
event: CodingConversationPatchBatchEvent,
|
||||||
|
current: ConversationSnapshot,
|
||||||
|
): boolean {
|
||||||
|
let mode = current.run.mode;
|
||||||
|
let observedActiveRun = current.run.status !== 'idle';
|
||||||
|
for (const item of event.items) {
|
||||||
|
if (item.patch.op !== 'run.state') continue;
|
||||||
|
if (item.patch.run.mode) mode = item.patch.run.mode;
|
||||||
|
if (item.patch.run.status !== 'idle') observedActiveRun = true;
|
||||||
|
if (observedActiveRun
|
||||||
|
&& mode === 'prompt'
|
||||||
|
&& item.patch.run.status === 'idle'
|
||||||
|
&& item.patch.run.terminalReason === 'completed') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export function createCodingConversationStore(
|
export function createCodingConversationStore(
|
||||||
dependencies: Partial<CodingConversationStoreDependencies> = {},
|
dependencies: Partial<CodingConversationStoreDependencies> = {},
|
||||||
): StoreApi<CodingConversationStoreState> {
|
): StoreApi<CodingConversationStoreState> {
|
||||||
@@ -892,7 +915,9 @@ export function createCodingConversationStore(
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const shouldSyncSettledSession = completesOrdinaryPrompt(event, currentSnapshot);
|
||||||
let recover = false;
|
let recover = false;
|
||||||
|
let applied = false;
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const current = state.entriesByConversationId[event.conversationId] ?? emptyEntry();
|
const current = state.entriesByConversationId[event.conversationId] ?? emptyEntry();
|
||||||
let reducer = current.reducer;
|
let reducer = current.reducer;
|
||||||
@@ -911,6 +936,7 @@ export function createCodingConversationStore(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (reducer === current.reducer) return state;
|
if (reducer === current.reducer) return state;
|
||||||
|
applied = true;
|
||||||
const incomingMessages = event.items.flatMap((item) => (
|
const incomingMessages = event.items.flatMap((item) => (
|
||||||
item.patch.op === 'message.upsert' ? [item.patch.node] : []
|
item.patch.op === 'message.upsert' ? [item.patch.node] : []
|
||||||
));
|
));
|
||||||
@@ -950,6 +976,9 @@ export function createCodingConversationStore(
|
|||||||
});
|
});
|
||||||
if (recover) {
|
if (recover) {
|
||||||
void get().loadSnapshot(event.conversationId, true).catch(() => undefined);
|
void get().loadSnapshot(event.conversationId, true).catch(() => undefined);
|
||||||
|
} else if (applied && shouldSyncSettledSession) {
|
||||||
|
const settledSnapshot = selectCodingConversationSnapshot(event.conversationId)(get());
|
||||||
|
if (settledSnapshot) deps.queueSettledSessionSync(settledSnapshot);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,218 +0,0 @@
|
|||||||
export type SessionRunQueuedPrompt = {
|
|
||||||
id: string;
|
|
||||||
text: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SessionRunPhase = 'idle' | 'posting' | 'running' | 'aborting';
|
|
||||||
|
|
||||||
export type SessionRunTerminalReason = 'completed' | 'aborted' | 'failed';
|
|
||||||
|
|
||||||
export type SessionRunState<TPrompt extends SessionRunQueuedPrompt = SessionRunQueuedPrompt> = {
|
|
||||||
phase: SessionRunPhase;
|
|
||||||
runId: number | null;
|
|
||||||
promptId: string | null;
|
|
||||||
queue: TPrompt[];
|
|
||||||
terminalReason: SessionRunTerminalReason | null;
|
|
||||||
error: string | null;
|
|
||||||
suppressNextAbortError: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SessionRunEvent<TPrompt extends SessionRunQueuedPrompt = SessionRunQueuedPrompt> =
|
|
||||||
| { type: 'send_started'; runId: number; promptId: string }
|
|
||||||
| { type: 'post_accepted'; runId: number }
|
|
||||||
| { type: 'remote_busy'; runId: number }
|
|
||||||
| { type: 'remote_idle'; runId: number }
|
|
||||||
| { type: 'remote_aborted'; runId: number }
|
|
||||||
| { type: 'remote_failed'; runId: number; error: string }
|
|
||||||
| { type: 'abort_requested'; runId: number }
|
|
||||||
| { type: 'abort_confirmed'; runId: number }
|
|
||||||
| { type: 'abort_failed'; runId: number; error: string }
|
|
||||||
| { type: 'queue_prompt'; prompt: TPrompt }
|
|
||||||
| { type: 'cancel_queued_prompt'; promptId: string };
|
|
||||||
|
|
||||||
export function createIdleSessionRunState<TPrompt extends SessionRunQueuedPrompt>(
|
|
||||||
queue: TPrompt[] = [],
|
|
||||||
): SessionRunState<TPrompt> {
|
|
||||||
return {
|
|
||||||
phase: 'idle',
|
|
||||||
runId: null,
|
|
||||||
promptId: null,
|
|
||||||
queue,
|
|
||||||
terminalReason: null,
|
|
||||||
error: null,
|
|
||||||
suppressNextAbortError: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isCurrentRunEvent<TPrompt extends SessionRunQueuedPrompt>(
|
|
||||||
state: SessionRunState<TPrompt>,
|
|
||||||
runId: number,
|
|
||||||
): boolean {
|
|
||||||
return state.runId === runId;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isSessionRunActive<TPrompt extends SessionRunQueuedPrompt>(
|
|
||||||
state: SessionRunState<TPrompt>,
|
|
||||||
): boolean {
|
|
||||||
return state.phase === 'posting' || state.phase === 'running';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function canStartQueuedPrompt<TPrompt extends SessionRunQueuedPrompt>(
|
|
||||||
state: SessionRunState<TPrompt>,
|
|
||||||
): boolean {
|
|
||||||
return state.phase === 'idle'
|
|
||||||
&& state.terminalReason !== 'failed'
|
|
||||||
&& state.queue.length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function takeNextQueuedPrompt<TPrompt extends SessionRunQueuedPrompt>(
|
|
||||||
state: SessionRunState<TPrompt>,
|
|
||||||
): {
|
|
||||||
prompt: TPrompt | null;
|
|
||||||
state: SessionRunState<TPrompt>;
|
|
||||||
} {
|
|
||||||
const [prompt, ...queue] = state.queue;
|
|
||||||
return {
|
|
||||||
prompt: prompt ?? null,
|
|
||||||
state: {
|
|
||||||
...state,
|
|
||||||
queue,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function didSuppressAbortError<TPrompt extends SessionRunQueuedPrompt>(
|
|
||||||
previousState: SessionRunState<TPrompt>,
|
|
||||||
nextState: SessionRunState<TPrompt>,
|
|
||||||
): boolean {
|
|
||||||
return previousState.suppressNextAbortError
|
|
||||||
&& !nextState.suppressNextAbortError
|
|
||||||
&& previousState.phase === nextState.phase
|
|
||||||
&& previousState.runId === nextState.runId
|
|
||||||
&& previousState.promptId === nextState.promptId
|
|
||||||
&& nextState.terminalReason === previousState.terminalReason
|
|
||||||
&& nextState.error === null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function transitionSessionRunState<TPrompt extends SessionRunQueuedPrompt>(
|
|
||||||
state: SessionRunState<TPrompt>,
|
|
||||||
event: SessionRunEvent<TPrompt>,
|
|
||||||
): SessionRunState<TPrompt> {
|
|
||||||
if (event.type === 'send_started') {
|
|
||||||
return {
|
|
||||||
phase: 'posting',
|
|
||||||
runId: event.runId,
|
|
||||||
promptId: event.promptId,
|
|
||||||
queue: state.queue,
|
|
||||||
terminalReason: null,
|
|
||||||
error: null,
|
|
||||||
suppressNextAbortError: state.suppressNextAbortError,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type === 'queue_prompt') {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
queue: [...state.queue, event.prompt],
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type === 'cancel_queued_prompt') {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
queue: state.queue.filter((prompt) => prompt.id !== event.promptId),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isCurrentRunEvent(state, event.runId)) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
state.phase === 'idle'
|
|
||||||
&& state.terminalReason !== null
|
|
||||||
&& (
|
|
||||||
event.type === 'post_accepted'
|
|
||||||
|| event.type === 'remote_busy'
|
|
||||||
|| event.type === 'remote_idle'
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (event.type) {
|
|
||||||
case 'post_accepted':
|
|
||||||
return state;
|
|
||||||
case 'remote_busy':
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
phase: 'running',
|
|
||||||
terminalReason: null,
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
case 'remote_idle':
|
|
||||||
if (state.phase === 'posting') return state;
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
phase: 'idle',
|
|
||||||
terminalReason: 'completed',
|
|
||||||
error: null,
|
|
||||||
suppressNextAbortError: false,
|
|
||||||
};
|
|
||||||
case 'remote_aborted':
|
|
||||||
if (state.suppressNextAbortError) {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
error: null,
|
|
||||||
suppressNextAbortError: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
phase: 'idle',
|
|
||||||
queue: [],
|
|
||||||
terminalReason: 'aborted',
|
|
||||||
error: null,
|
|
||||||
suppressNextAbortError: false,
|
|
||||||
};
|
|
||||||
case 'remote_failed':
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
phase: 'idle',
|
|
||||||
queue: [],
|
|
||||||
terminalReason: 'failed',
|
|
||||||
error: event.error,
|
|
||||||
suppressNextAbortError: false,
|
|
||||||
};
|
|
||||||
case 'abort_requested':
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
phase: 'aborting',
|
|
||||||
queue: [],
|
|
||||||
terminalReason: 'aborted',
|
|
||||||
error: null,
|
|
||||||
suppressNextAbortError: true,
|
|
||||||
};
|
|
||||||
case 'abort_confirmed':
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
phase: 'idle',
|
|
||||||
queue: [],
|
|
||||||
terminalReason: 'aborted',
|
|
||||||
error: null,
|
|
||||||
suppressNextAbortError: false,
|
|
||||||
};
|
|
||||||
case 'abort_failed':
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
phase: 'idle',
|
|
||||||
queue: [],
|
|
||||||
terminalReason: 'failed',
|
|
||||||
error: event.error,
|
|
||||||
suppressNextAbortError: false,
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,10 +3,12 @@ import type { RawMessage } from '@/types/chat';
|
|||||||
import {
|
import {
|
||||||
buildAgentSessionData,
|
buildAgentSessionData,
|
||||||
flushPendingAgentSessionSync,
|
flushPendingAgentSessionSync,
|
||||||
|
queueCodingConversationSessionSync,
|
||||||
queueAgentSessionSync,
|
queueAgentSessionSync,
|
||||||
resetAgentSessionSyncForTests,
|
resetAgentSessionSyncForTests,
|
||||||
sanitizeAgentSessionText,
|
sanitizeAgentSessionText,
|
||||||
} from '@/lib/agent-session-sync';
|
} from '@/lib/agent-session-sync';
|
||||||
|
import { createProductSnapshot } from '../fixtures/coding-conversation-product-fixtures';
|
||||||
|
|
||||||
const getAuthStateMock = vi.hoisted(() => vi.fn());
|
const getAuthStateMock = vi.hoisted(() => vi.fn());
|
||||||
const getProfileStateMock = vi.hoisted(() => vi.fn());
|
const getProfileStateMock = vi.hoisted(() => vi.fn());
|
||||||
@@ -145,6 +147,79 @@ describe('local Agent session sync', () => {
|
|||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uploads natural-language messages from the final product Conversation snapshot', async () => {
|
||||||
|
queueCodingConversationSessionSync({
|
||||||
|
...createProductSnapshot('conversation-1'),
|
||||||
|
conversation: {
|
||||||
|
...createProductSnapshot('conversation-1').conversation,
|
||||||
|
projectId: 'project-1',
|
||||||
|
},
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
kind: 'message',
|
||||||
|
id: 'user-1',
|
||||||
|
role: 'user',
|
||||||
|
status: 'complete',
|
||||||
|
blocks: [{ kind: 'text', id: 'user-text', text: '解释一下。', status: 'complete' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'message',
|
||||||
|
id: 'assistant-1',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'complete',
|
||||||
|
blocks: [
|
||||||
|
{ kind: 'thinking', id: 'thought', text: '内部推理', status: 'complete' },
|
||||||
|
{ kind: 'text', id: 'answer', text: '这是自然语言回答。', status: 'complete' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
run: {
|
||||||
|
status: 'idle',
|
||||||
|
runId: 'run-1',
|
||||||
|
settledAt: Date.parse('2026-08-24T08:00:00.000Z'),
|
||||||
|
terminalReason: 'completed',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await flushPendingAgentSessionSync();
|
||||||
|
|
||||||
|
expect(pushSessionDataMock).toHaveBeenCalledWith(
|
||||||
|
'user-a',
|
||||||
|
'access-token',
|
||||||
|
{
|
||||||
|
project_id: 'project-1',
|
||||||
|
session_id: 'conversation-1',
|
||||||
|
updated_at: '2026-08-24T08:00:00.000Z',
|
||||||
|
messages: [
|
||||||
|
{ id: 'user-1', role: 'user', text: '解释一下。' },
|
||||||
|
{ id: 'assistant-1', role: 'assistant', text: '这是自然语言回答。' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not queue a product Conversation without assistant natural language', async () => {
|
||||||
|
const base = createProductSnapshot('conversation-1');
|
||||||
|
queueCodingConversationSessionSync({
|
||||||
|
...base,
|
||||||
|
nodes: [{
|
||||||
|
kind: 'message',
|
||||||
|
id: 'assistant-1',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'complete',
|
||||||
|
blocks: [{ kind: 'thinking', id: 'thought', text: '内部推理', status: 'complete' }],
|
||||||
|
}],
|
||||||
|
run: {
|
||||||
|
status: 'idle',
|
||||||
|
runId: 'run-1',
|
||||||
|
settledAt: Date.parse('2026-08-24T08:00:00.000Z'),
|
||||||
|
terminalReason: 'completed',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await flushPendingAgentSessionSync();
|
||||||
|
|
||||||
|
expect(pushSessionDataMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('never uploads a pending snapshot under a different authenticated account', async () => {
|
it('never uploads a pending snapshot under a different authenticated account', async () => {
|
||||||
queueAgentSessionSync('prj_1', 'ses_1', [
|
queueAgentSessionSync('prj_1', 'ses_1', [
|
||||||
{ id: 'user-1', role: 'user', content: '账号 A 的问题' },
|
{ id: 'user-1', role: 'user', content: '账号 A 的问题' },
|
||||||
|
|||||||
@@ -138,6 +138,75 @@ function ids() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('coding Conversation store', () => {
|
describe('coding Conversation store', () => {
|
||||||
|
it('queues observation sync only for a live completed ordinary prompt', () => {
|
||||||
|
const queueSettledSessionSync = vi.fn();
|
||||||
|
const store = createCodingConversationStore({
|
||||||
|
getSnapshot: vi.fn(),
|
||||||
|
openEvents: vi.fn(),
|
||||||
|
submitPrompt: vi.fn(),
|
||||||
|
queueSettledSessionSync,
|
||||||
|
createId: ids(),
|
||||||
|
});
|
||||||
|
const running = {
|
||||||
|
...snapshot('conversation-a', 1, 3),
|
||||||
|
run: { status: 'running', runId: 'run-a', mode: 'prompt' } as const,
|
||||||
|
};
|
||||||
|
store.getState().applySnapshotEvent(snapshotEvent(running));
|
||||||
|
|
||||||
|
expect(queueSettledSessionSync).not.toHaveBeenCalled();
|
||||||
|
store.getState().applyPatchBatchEvent(patchEvent('conversation-a', 4, {
|
||||||
|
op: 'run.state',
|
||||||
|
run: {
|
||||||
|
status: 'idle',
|
||||||
|
runId: 'run-a',
|
||||||
|
settledAt: 1_004,
|
||||||
|
terminalReason: 'completed',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(queueSettledSessionSync).toHaveBeenCalledTimes(1);
|
||||||
|
expect(queueSettledSessionSync).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
conversation: expect.objectContaining({ id: 'conversation-a', projectId: 'project-a' }),
|
||||||
|
run: expect.objectContaining({ status: 'idle', terminalReason: 'completed' }),
|
||||||
|
cursor: expect.objectContaining({ seq: 4 }),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not queue observation sync for hydration or a follow-up settlement', () => {
|
||||||
|
const queueSettledSessionSync = vi.fn();
|
||||||
|
const store = createCodingConversationStore({
|
||||||
|
getSnapshot: vi.fn(),
|
||||||
|
openEvents: vi.fn(),
|
||||||
|
submitPrompt: vi.fn(),
|
||||||
|
queueSettledSessionSync,
|
||||||
|
createId: ids(),
|
||||||
|
});
|
||||||
|
store.getState().applySnapshotEvent(snapshotEvent({
|
||||||
|
...snapshot('conversation-a', 1, 3),
|
||||||
|
run: {
|
||||||
|
status: 'idle',
|
||||||
|
runId: 'hydrated-run',
|
||||||
|
settledAt: 1_003,
|
||||||
|
terminalReason: 'completed',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
store.getState().applySnapshotEvent(snapshotEvent({
|
||||||
|
...snapshot('conversation-b', 1, 3),
|
||||||
|
run: { status: 'running', runId: 'run-b', mode: 'follow-up' },
|
||||||
|
}));
|
||||||
|
store.getState().applyPatchBatchEvent(patchEvent('conversation-b', 4, {
|
||||||
|
op: 'run.state',
|
||||||
|
run: {
|
||||||
|
status: 'idle',
|
||||||
|
runId: 'run-b',
|
||||||
|
settledAt: 1_004,
|
||||||
|
terminalReason: 'completed',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(queueSettledSessionSync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('isolates interleaved snapshot and patch state for two Conversations', async () => {
|
it('isolates interleaved snapshot and patch state for two Conversations', async () => {
|
||||||
const source = new FakeEventSource();
|
const source = new FakeEventSource();
|
||||||
const getSnapshot = vi.fn(async (conversationId: string) => snapshot(conversationId));
|
const getSnapshot = vi.fn(async (conversationId: string) => snapshot(conversationId));
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ describe('coding project v1 to v2 migration', () => {
|
|||||||
]);
|
]);
|
||||||
expect(resolveLegacyModel).toHaveBeenCalledTimes(2);
|
expect(resolveLegacyModel).toHaveBeenCalledTimes(2);
|
||||||
expect(result.removedGeneratedAgents.sort()).toEqual(['no-account.md', 'unique.md']);
|
expect(result.removedGeneratedAgents.sort()).toEqual(['no-account.md', 'unique.md']);
|
||||||
expect(result.backedUpUncertainAgents).toEqual(['unresolved.md']);
|
expect(result.backedUpUncertainAgents).toEqual(['custom.md', 'unresolved.md']);
|
||||||
expect(JSON.parse(await readFile(
|
expect(JSON.parse(await readFile(
|
||||||
path.join(staged.projectPath, '.niancode', 'conversations.json'),
|
path.join(staged.projectPath, '.niancode', 'conversations.json'),
|
||||||
'utf8',
|
'utf8',
|
||||||
@@ -215,8 +215,12 @@ describe('coding project v1 to v2 migration', () => {
|
|||||||
path.join(result.backupDirectory, '.opencode', 'agent', 'unresolved.md'),
|
path.join(result.backupDirectory, '.opencode', 'agent', 'unresolved.md'),
|
||||||
'utf8',
|
'utf8',
|
||||||
)).toBe('locally modified Agent\n');
|
)).toBe('locally modified Agent\n');
|
||||||
|
expect(await readFile(
|
||||||
|
path.join(result.backupDirectory, '.opencode', 'agent', 'custom.md'),
|
||||||
|
'utf8',
|
||||||
|
)).toBe('unknown Agent\n');
|
||||||
await expect(readFile(unresolvedFile, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
await expect(readFile(unresolvedFile, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
||||||
expect(await readFile(customFile, 'utf8')).toBe('unknown Agent\n');
|
await expect(readFile(customFile, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
||||||
expect(await readFile(path.join(staged.projectPath, '.opencode', 'skills', 'keep.md'), 'utf8'))
|
expect(await readFile(path.join(staged.projectPath, '.opencode', 'skills', 'keep.md'), 'utf8'))
|
||||||
.toBe('keep me');
|
.toBe('keep me');
|
||||||
|
|
||||||
@@ -249,6 +253,8 @@ describe('coding project v1 to v2 migration', () => {
|
|||||||
|
|
||||||
it('restores retryable v1 state after a corrupt metadata write and succeeds on retry', async () => {
|
it('restores retryable v1 state after a corrupt metadata write and succeeds on retry', async () => {
|
||||||
const staged = await stageLegacyProject();
|
const staged = await stageLegacyProject();
|
||||||
|
const customFile = path.join(staged.projectPath, '.opencode', 'agent', 'custom.md');
|
||||||
|
await writeFile(customFile, 'unknown Agent\n', 'utf8');
|
||||||
let writeCount = 0;
|
let writeCount = 0;
|
||||||
const corruptingWriter = vi.fn(async (filePath: string, value: unknown) => {
|
const corruptingWriter = vi.fn(async (filePath: string, value: unknown) => {
|
||||||
writeCount += 1;
|
writeCount += 1;
|
||||||
@@ -273,6 +279,7 @@ describe('coding project v1 to v2 migration', () => {
|
|||||||
expect(await readFile(path.join(staged.projectPath, '.opencode', entry.relativePath), 'utf8'))
|
expect(await readFile(path.join(staged.projectPath, '.opencode', entry.relativePath), 'utf8'))
|
||||||
.toBe(entry.content);
|
.toBe(entry.content);
|
||||||
}
|
}
|
||||||
|
expect(await readFile(customFile, 'utf8')).toBe('unknown Agent\n');
|
||||||
|
|
||||||
const retry = await migrateCodingProjectToV2(staged.projectPath, {
|
const retry = await migrateCodingProjectToV2(staged.projectPath, {
|
||||||
resolveLegacyModel: async ({ legacyModel }) => legacyModel === 'legacy/unique' ? MODEL : null,
|
resolveLegacyModel: async ({ legacyModel }) => legacyModel === 'legacy/unique' ? MODEL : null,
|
||||||
@@ -283,5 +290,11 @@ describe('coding project v1 to v2 migration', () => {
|
|||||||
path.join(staged.projectPath, '.niancode', 'conversations.json'),
|
path.join(staged.projectPath, '.niancode', 'conversations.json'),
|
||||||
'utf8',
|
'utf8',
|
||||||
))).toEqual({ schemaVersion: 2, conversations: [] });
|
))).toEqual({ schemaVersion: 2, conversations: [] });
|
||||||
|
expect(retry.backedUpUncertainAgents).toEqual(['custom.md']);
|
||||||
|
await expect(readFile(customFile, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
||||||
|
expect(await readFile(
|
||||||
|
path.join(retry.backupDirectory, '.opencode', 'agent', 'custom.md'),
|
||||||
|
'utf8',
|
||||||
|
)).toBe('unknown Agent\n');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ Content-Type: application/json
|
|||||||
| 字段 | 类型 | 必填 | 说明 |
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `project_id` | string | 是 | 本地项目稳定标识;不是项目路径,也不是项目名称 |
|
| `project_id` | string | 是 | 本地项目稳定标识;不是项目路径,也不是项目名称 |
|
||||||
| `session_id` | string | 是 | OpenCode 会话标识;服务端按不透明字符串保存 |
|
| `session_id` | string | 是 | Makelore Coding Conversation 标识;服务端按不透明字符串保存 |
|
||||||
| `updated_at` | ISO 8601 string | 是 | 客户端生成的本次快照时间,统一为 UTC ISO 字符串 |
|
| `updated_at` | ISO 8601 string | 是 | 客户端生成的本次快照时间,统一为 UTC ISO 字符串 |
|
||||||
| `messages` | array | 是 | 按本地会话顺序排列的完整问答消息 |
|
| `messages` | array | 是 | 按本地会话顺序排列的完整问答消息 |
|
||||||
|
|
||||||
@@ -326,8 +326,8 @@ curl -X PUT 'https://square.nianxx.cn/api/user/agent-profile' \
|
|||||||
|
|
||||||
如服务端需要核对客户端字段或测试,可参考:
|
如服务端需要核对客户端字段或测试,可参考:
|
||||||
|
|
||||||
- `src/lib/agent-session-sync.ts`:快照构建、过滤、本地队列、账号隔离和重试;
|
- `src/lib/agent-session-sync.ts`:Coding Conversation 快照构建、过滤、本地队列、账号隔离和重试;
|
||||||
- `src/lib/agent-profile.ts`:个人资料 PUT 请求类型;
|
- `src/lib/agent-profile.ts`:个人资料 PUT 请求类型;
|
||||||
- `src/stores/user-profile.ts`:将 `session_data` 伴随个人资料上传;
|
- `src/stores/user-profile.ts`:将 `session_data` 伴随个人资料上传;
|
||||||
- `src/stores/opencode.ts`:普通问答完成后的后台触发点;
|
- `src/stores/coding-conversations.ts`:普通 prompt 在实时 `agent_settled` 对应的最终 Snapshot 完成后的后台触发点;
|
||||||
- `tests/unit/agent-session-sync.test.ts`:过滤、上传、失败重试和跨账号保护测试。
|
- `tests/unit/agent-session-sync.test.ts`:过滤、上传、失败重试和跨账号保护测试。
|
||||||
|
|||||||
Reference in New Issue
Block a user