feat(rewrite): bootstrap monorepo Bun + port domain layer ke TypeScript
Mulai migrasi Zesdex dari Rust ke TypeScript/Bun.
- Root workspace package.json, tsconfig strict, bun.lock, gitignore fix
- Paket @zesdex/domain (apps/packages/domain):
- core: ChatMessage/Conversation/ToolCall/SseParser/repairJson/
sanitizeToolArguments/UsageStats/Store/DomainError
- auth: Session/SessionId/SessionLock/OAuth + repository & service interfaces
- cms: AppConfig/Settings/SettingsPatch/Memory/EditLog + repository &
service interfaces
- agent: TurnEvent/SessionRuntime/AgentTurnParams/AgentProgress/prompt
- subagent & workflow models
- Wire-shape JSON disamakan dengan Rust (serde rename/flatten/skip_if)
- 27 unit test Bun port dari test Rust (SseParser, repair_json, resolve_effective_model, session id)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9d1544d799
commit
07e84e43b3
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Command types for IAM domain operations. Mirrors `commands.rs`.
|
||||
* Carries only the data needed to construct a session entity.
|
||||
*/
|
||||
export interface NewSession {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function newSessionCommand(title: string): NewSession {
|
||||
return { title };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Domain error types for the IAM (auth) module.
|
||||
* Mirrors `apps/domain/src/auth/error.rs`.
|
||||
*/
|
||||
import type { DomainError } from "../core/error.ts";
|
||||
|
||||
/** Shared repository error type for IAM persistence. */
|
||||
export type RepositoryError = DomainError;
|
||||
|
||||
/** Errors from service / use-case operations in the IAM domain. */
|
||||
export type ServiceError =
|
||||
| { kind: "repository"; error: DomainError }
|
||||
| { kind: "invalid_config"; message: string }
|
||||
| { kind: "state_mismatch" }
|
||||
| { kind: "oauth_provider"; message: string }
|
||||
| { kind: "other"; message: string };
|
||||
|
||||
export function repositoryErr(err: DomainError): ServiceError {
|
||||
return { kind: "repository", error: err };
|
||||
}
|
||||
export function invalidConfig(message: string): ServiceError {
|
||||
return { kind: "invalid_config", message };
|
||||
}
|
||||
export function stateMismatch(): ServiceError {
|
||||
return { kind: "state_mismatch" };
|
||||
}
|
||||
export function oauthProvider(message: string): ServiceError {
|
||||
return { kind: "oauth_provider", message };
|
||||
}
|
||||
export function authOther(message: string): ServiceError {
|
||||
return { kind: "other", message };
|
||||
}
|
||||
|
||||
export function serviceErrorToString(e: ServiceError): string {
|
||||
switch (e.kind) {
|
||||
case "repository":
|
||||
return `repository error: ${e.error.message}`;
|
||||
case "invalid_config":
|
||||
return `invalid configuration: ${e.message}`;
|
||||
case "state_mismatch":
|
||||
return `OAuth state mismatch — possible CSRF attack`;
|
||||
case "oauth_provider":
|
||||
return `OAuth provider error: ${e.message}`;
|
||||
case "other":
|
||||
return e.message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/** Auth domain module — sessions, locks, OAuth, and their interfaces. */
|
||||
export * from "./session.ts";
|
||||
export * from "./session_id.ts";
|
||||
export * from "./session_lock.ts";
|
||||
export * from "./oauth.ts";
|
||||
export * from "./repository.ts";
|
||||
export * from "./service.ts";
|
||||
export * from "./commands.ts";
|
||||
export {
|
||||
type ServiceError as AuthServiceError,
|
||||
type RepositoryError as AuthRepositoryError,
|
||||
} from "./error.ts";
|
||||
export {
|
||||
repositoryErr as authRepositoryErr,
|
||||
invalidConfig as authInvalidConfig,
|
||||
stateMismatch as authStateMismatch,
|
||||
oauthProvider as authOauthProvider,
|
||||
authOther as authOtherError,
|
||||
serviceErrorToString as authServiceErrorToString,
|
||||
} from "./error.ts";
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* OAuth entities. Mirrors `apps/domain/src/auth/oauth.rs`.
|
||||
*/
|
||||
export interface OAuthToken {
|
||||
/** The OAuth 2.0 access token string. */
|
||||
access_token: string;
|
||||
/** Optional refresh token for long-lived access. */
|
||||
refresh_token?: string;
|
||||
/** Absolute expiry timestamp (epoch seconds). */
|
||||
expires_at: number;
|
||||
/** Token type, e.g. `"Bearer"`. */
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
/** Static configuration for an OAuth provider. */
|
||||
export interface OAuthConfig {
|
||||
/** Authorization endpoint URL. */
|
||||
auth_url: string;
|
||||
/** Token exchange endpoint URL. */
|
||||
token_url: string;
|
||||
/** OAuth client identifier. */
|
||||
client_id: string;
|
||||
/** Optional client secret. */
|
||||
client_secret?: string;
|
||||
/** Space-separated list of requested scopes. */
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
/** Default scopes for a new OAuthConfig. */
|
||||
export const DEFAULT_OAUTH_SCOPES = ["openid", "profile", "email"];
|
||||
|
||||
export function newOAuthConfig(): OAuthConfig {
|
||||
return {
|
||||
auth_url: "",
|
||||
token_url: "",
|
||||
client_id: "",
|
||||
client_secret: undefined,
|
||||
scopes: [...DEFAULT_OAUTH_SCOPES],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Repository trait definitions (interfaces) — pure, no impls.
|
||||
* Mirrors `apps/domain/src/auth/repository.rs`. Infrastructure adapters
|
||||
* implement these.
|
||||
*/
|
||||
import type { OAuthToken } from "./oauth.ts";
|
||||
import type { Session } from "./session.ts";
|
||||
import type { SessionId } from "./session_id.ts";
|
||||
|
||||
/** Repository for loading, saving, listing, and deleting sessions. */
|
||||
export interface SessionRepository {
|
||||
/** List all loadable sessions under `<base_dir>/sessions/`. */
|
||||
listSessions(baseDir: string): Promise<Session[]> | Session[];
|
||||
/** Load a single session by id. */
|
||||
loadSession(baseDir: string, id: SessionId): Promise<Session> | Session;
|
||||
/** Save a session's metadata to disk. */
|
||||
saveSession(baseDir: string, session: Session): Promise<void> | void;
|
||||
/** Delete a session directory and all its contents. */
|
||||
deleteSession(baseDir: string, id: SessionId): Promise<void> | void;
|
||||
}
|
||||
|
||||
/** Repository for per-session PID-file advisory locks. */
|
||||
export interface SessionLockRepository {
|
||||
/** Try to acquire the lock. `true` if acquired, `false` if a live process holds it. */
|
||||
tryLock(sessionDir: string): Promise<boolean> | boolean;
|
||||
/** Release the lock. */
|
||||
unlock(sessionDir: string): Promise<void> | void;
|
||||
/** Check whether a process with the given PID is alive. */
|
||||
isAlive(pid: number): boolean;
|
||||
}
|
||||
|
||||
/** Repository for persisting and loading OAuth tokens. */
|
||||
export interface OAuthRepository {
|
||||
/** Persist an OAuth token to a JSON file. */
|
||||
saveToken(path: string, token: OAuthToken): Promise<void> | void;
|
||||
/** Load an OAuth token, returning `null` if the file does not exist. */
|
||||
loadToken(path: string): Promise<OAuthToken | null> | OAuthToken | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Service trait definitions — use-case boundaries for sessions and OAuth.
|
||||
* Mirrors `apps/domain/src/auth/service.rs`. Implementations live in the
|
||||
* application layer.
|
||||
*/
|
||||
import type { OAuthConfig, OAuthToken } from "./oauth.ts";
|
||||
import type { Session } from "./session.ts";
|
||||
import type { SessionId } from "./session_id.ts";
|
||||
|
||||
/** Session management use-case boundary. */
|
||||
export interface SessionService {
|
||||
/** Create a new session with a generated UUID and the given title. */
|
||||
createSession(title: string): Promise<Session> | Session;
|
||||
/** List all available sessions. */
|
||||
listAll(): Promise<Session[]> | Session[];
|
||||
/** Archive a session by id (sets `archived = true`). */
|
||||
archiveSession(id: SessionId): Promise<void> | void;
|
||||
}
|
||||
|
||||
/** OAuth flow use-case boundary. */
|
||||
export interface OAuthService {
|
||||
/**
|
||||
* Start an OAuth authorization-code + PKCE flow. Returns `{ authUrl, state }`:
|
||||
* the URL to send the user to, and the CSRF state token that must be passed
|
||||
* back into `completeFlow` unchanged.
|
||||
*/
|
||||
startFlow(config: OAuthConfig, redirectUri: string): Promise<{ authUrl: string; state: string }> | { authUrl: string; state: string };
|
||||
/**
|
||||
* Complete the flow: validate `state`, then exchange `code` for a token.
|
||||
* Validates `state` against the persisted value (CSRF check).
|
||||
*/
|
||||
completeFlow(config: OAuthConfig, redirectUri: string, code: string, state: string): Promise<OAuthToken> | OAuthToken;
|
||||
/** Retrieve the currently stored OAuth token (if any). */
|
||||
getToken(): Promise<OAuthToken | null> | OAuthToken | null;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Session metadata. Mirrors `apps/domain/src/auth/session.rs`.
|
||||
*/
|
||||
import * as path from "node:path";
|
||||
|
||||
export interface Session {
|
||||
/** Unique session identifier. */
|
||||
id: string;
|
||||
/** Epoch-millis timestamp of creation. */
|
||||
created_at: number;
|
||||
/** Epoch-millis timestamp of last update. */
|
||||
updated_at: number;
|
||||
/** Human-readable title for the conversation. */
|
||||
title: string;
|
||||
/** Model identifier string. */
|
||||
model: string;
|
||||
/** Workspace root directories associated with this session. */
|
||||
workspace_roots: string[];
|
||||
/** Running count of messages in the conversation. */
|
||||
message_count: number;
|
||||
/** Running count of tokens consumed. */
|
||||
token_count: number;
|
||||
/** Soft-delete flag. */
|
||||
archived: boolean;
|
||||
/** Optional AI-generated conversation summary. */
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_MODEL = "anthropic/claude-opus-4-8";
|
||||
|
||||
/** Create a new session with the given id/title and current dir as root. */
|
||||
export function newSession(id: string, title: string): Session {
|
||||
const now = Date.now();
|
||||
return {
|
||||
id,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
title,
|
||||
model: DEFAULT_MODEL,
|
||||
workspace_roots: [process.cwd()],
|
||||
message_count: 0,
|
||||
token_count: 0,
|
||||
archived: false,
|
||||
summary: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Compute this session's directory under `<base_dir>/sessions/<id>`. */
|
||||
export function sessionDir(session: Session, baseDir: string): string {
|
||||
return path.join(baseDir, "sessions", session.id);
|
||||
}
|
||||
|
||||
/** Compute this session's `conversation.json` path. */
|
||||
export function conversationPath(session: Session, baseDir: string): string {
|
||||
return path.join(sessionDir(session, baseDir), "conversation.json");
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { newSessionId } from "./session_id.ts";
|
||||
|
||||
describe("newSessionId", () => {
|
||||
it("accepts valid UUIDs", () => {
|
||||
expect(newSessionId("550e8400-e29b-41d4-a716-446655440000").ok).toBe(true);
|
||||
expect(newSessionId("my-session_123").ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects path traversal", () => {
|
||||
expect(newSessionId("../etc/passwd").ok).toBe(false);
|
||||
expect(newSessionId("foo/../../bar").ok).toBe(false);
|
||||
expect(newSessionId("foo\\..\\bar").ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects empty", () => {
|
||||
expect(newSessionId("").ok).toBe(false);
|
||||
});
|
||||
|
||||
it("returns the id string on success", () => {
|
||||
const res = newSessionId("abc-123");
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) expect(res.value).toBe("abc-123");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Validated session identifier. Mirrors `apps/domain/src/auth/session_id.rs`.
|
||||
*
|
||||
* Guarantees the inner string is non-empty and contains no path-traversal
|
||||
* characters (`/`, `\`, `..`) or other unsafe delimiters.
|
||||
*/
|
||||
|
||||
const SESSION_ID_RE = /^[A-Za-z0-9_.-]+$/;
|
||||
|
||||
export type SessionId = string & { __sessionId?: true };
|
||||
|
||||
/**
|
||||
* Validate and construct a `SessionId`.
|
||||
* Returns `err` message if the input contains path separators, `..`, or is empty.
|
||||
*/
|
||||
export function newSessionId(id: string): { ok: true; value: SessionId } | { ok: false; error: string } {
|
||||
if (id === "") {
|
||||
return { ok: false, error: "session id must not be empty" };
|
||||
}
|
||||
if (id.includes("/") || id.includes("\\") || id.includes("..") || !SESSION_ID_RE.test(id)) {
|
||||
return { ok: false, error: `session id '${id}' must not contain path separators` };
|
||||
}
|
||||
return { ok: true, value: id as SessionId };
|
||||
}
|
||||
|
||||
/** Assert that a session id is valid (throws if not). */
|
||||
export function assertSessionId(id: string): SessionId {
|
||||
const res = newSessionId(id);
|
||||
if (!res.ok) throw new Error(res.error);
|
||||
return res.value;
|
||||
}
|
||||
|
||||
/** Return the underlying string. */
|
||||
export function sessionIdAsString(id: SessionId): string {
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Classic Rust-style UUID format (as used by the Rust app). */
|
||||
export function isUuidLike(id: string): boolean {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* PID-file based advisory lock preventing two processes from operating on
|
||||
* the same session directory concurrently. Mirrors `session_lock.rs`.
|
||||
*
|
||||
* Strategy: atomic `O_CREAT|O_EXCL` acquire → on existing file, liveness-check
|
||||
* the owning PID (Unix: read `/proc/<pid>/exe` and compare to self, plus
|
||||
* `kill(pid, 0)`) → stale locks overwritten atomically (temp + rename + fsync).
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
/** A PID-file lock (`<session_dir>/.lock`) tied to the current process. */
|
||||
export class SessionLock {
|
||||
readonly path: string;
|
||||
readonly pid: number;
|
||||
|
||||
constructor(sessionDir: string) {
|
||||
this.path = path.join(sessionDir, ".lock");
|
||||
this.pid = process.pid;
|
||||
}
|
||||
|
||||
/** Attempt to acquire the lock. Returns `true` on success, `false` if held by a live process. */
|
||||
tryLock(): boolean {
|
||||
// Phase 1: atomic create (O_CREAT|O_EXCL equivalent via flag 'wx').
|
||||
try {
|
||||
const fd = fs.openSync(this.path, "wx");
|
||||
try {
|
||||
fs.writeFileSync(fd, String(this.pid));
|
||||
fs.fsyncSync(fd);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
const code = (e as NodeJS.ErrnoException).code;
|
||||
if (code !== "EEXIST") throw e;
|
||||
// Lock file exists — check staleness.
|
||||
}
|
||||
|
||||
// Phase 2: read owning PID and check liveness.
|
||||
const content = fs.readFileSync(this.path, "utf8").trim();
|
||||
if (content !== "") {
|
||||
const pid = Number(content);
|
||||
if (Number.isFinite(pid) && pid > 0) {
|
||||
if (pidIsAlive(pid)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: stale lock — overwrite atomically.
|
||||
const tmp = this.path + ".tmp";
|
||||
const fd = fs.openSync(tmp, "w");
|
||||
try {
|
||||
fs.writeFileSync(fd, String(this.pid));
|
||||
fs.fsyncSync(fd);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
fs.renameSync(tmp, this.path);
|
||||
// Best-effort parent dir fsync.
|
||||
const parent = path.dirname(this.path);
|
||||
try {
|
||||
const dirFd = fs.openSync(parent, "r");
|
||||
try {
|
||||
fs.fsyncSync(dirFd);
|
||||
} finally {
|
||||
fs.closeSync(dirFd);
|
||||
}
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Explicitly release the lock by removing the lock file. */
|
||||
unlock(): void {
|
||||
try {
|
||||
fs.unlinkSync(this.path);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a process with the given PID is alive. Conservative on
|
||||
* non-Unix: returns true.
|
||||
*/
|
||||
export function pidIsAlive(pid: number): boolean {
|
||||
if (process.platform === "win32") return true;
|
||||
try {
|
||||
const selfExe = fs.readlinkSync("/proc/self/exe");
|
||||
let target: string;
|
||||
try {
|
||||
target = fs.readlinkSync(`/proc/${pid}/exe`);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (target !== selfExe) return false;
|
||||
// kill(pid, 0): throws if the process is gone / not permitted.
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
// Re-check to close the TOCTOU window.
|
||||
try {
|
||||
return fs.readlinkSync(`/proc/${pid}/exe`) === selfExe;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
// No /proc (macOS) — fall back to kill(pid,0) only.
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user