Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/kernel/mutex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* 按 key 隔离的互斥锁,用于单进程内串行化并发操作。
* 相同 key 的操作依次执行,不同 key 的操作可并行。
*/
export class KeyedMutex {
private locks = new Map<string | number, Promise<void>>()

async runExclusive<T>(key: string | number, fn: () => Promise<T>): Promise<T> {
const prev = this.locks.get(key) ?? Promise.resolve()
let release: () => void
const next = new Promise<void>(r => {
release = r
})
this.locks.set(key, next)

await prev
try {
return await fn()
} finally {
release!()
}
}
}
81 changes: 81 additions & 0 deletions src/kernel/slug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
export const MAX_SLUG_LENGTH = 60

export type BranchType = "feat" | "fix" | "chore" | "docs"

export type SlugValidationResult =
| { ok: true; slug: string }
| { ok: false; code: "EMPTY_TITLE" | "INVALID_FORMAT" | "SLUG_TOO_LONG" | "GENERIC_TITLE"; message: string }

/** 合法 slug:小写字母/数字分段,段间单个连字符 */
const SLUG_FORMAT = /^[a-z0-9]+(-[a-z0-9]+)*$/

/** 泛化/占位词:与数字或其他泛化词组合时无法表达具体功能 */
const GENERIC_WORDS = new Set([
"add", "bug", "bugs", "chore", "create", "delete", "docs", "feature",
"features", "fix", "fixes", "implement", "issue", "issues", "refactor",
"remove", "support", "task", "tasks", "test", "tests", "update", "wip",
])

/**
* 严格 slug 派生:沿用 util/paths.ts slugify 的归一化规则,
* 但不做 "doc" 兜底——无意义输入返回空串,由调用方判定。
*/
export function deriveSlug(value: string): string {
let slug = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.replace(/--+/g, "-")

if (slug.length > MAX_SLUG_LENGTH) {
const sliced = slug.slice(0, MAX_SLUG_LENGTH)
const lastDash = sliced.lastIndexOf("-")
slug = lastDash > MAX_SLUG_LENGTH / 2 ? sliced.slice(0, lastDash) : sliced
}

return slug
}

/**
* 校验功能标题(须已是 kebab-case slug):
* - 空标题、非法字符(大写/下划线/空格/连续连字符/首尾连字符)、过长(>60)→ 格式拒绝
* - 纯序号或全部由泛化词组成(task-001、implement-task、fix-bug)→ GENERIC_TITLE
*/
export function validateTitle(title: string): SlugValidationResult {
if (title.trim() === "") {
return { ok: false, code: "EMPTY_TITLE", message: "Title must not be empty" }
}
if (title.length > MAX_SLUG_LENGTH) {
return { ok: false, code: "SLUG_TOO_LONG", message: `Title must be at most ${MAX_SLUG_LENGTH} characters` }
}
if (!SLUG_FORMAT.test(title)) {
return {
ok: false,
code: "INVALID_FORMAT",
message: "Title must be a kebab-case slug: lowercase letters, digits, and single hyphens between segments",
}
}
if (isGenericSlug(title)) {
return {
ok: false,
code: "GENERIC_TITLE",
message: "Title is too generic: use a functional slug that describes the concrete behavior",
}
}
return { ok: true, slug: title }
}

/** 纯序号,或所有分段均为泛化词/数字 → 无法描述具体功能 */
function isGenericSlug(slug: string): boolean {
const segments = slug.split("-")
return segments.every(seg => /^\d+$/.test(seg) || GENERIC_WORDS.has(seg))
}

export function branchFor(slug: string, type: BranchType): string {
return `${type}/${slug}`
}

export function worktreeFor(slug: string): string {
return `.worktree/${slug}`
}
12 changes: 12 additions & 0 deletions src/kernel/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/** Flow Record 引用:GitHub Parent Issue 承载 Flow 生命周期与阶段状态 */
export interface FlowRecordRef {
parentIssueNumber: number
slug: string
}

/** Task Record 引用:GitHub Sub Issue 承载 Task 生命周期与 TDD 证据 */
export interface TaskRecordRef {
parentIssueNumber: number
taskId: string
issueNumber: number
}
29 changes: 1 addition & 28 deletions src/plugin/broker.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { FlowRun } from "../flowrun/types.js"
import { readFlowRunWithLock, writeFlowRunWithLock as ghWriteFlowRunWithLock } from "../flowrun/github.js"
import { KeyedMutex } from "../kernel/mutex.js"

// ─── 类型 ───

Expand All @@ -18,34 +19,6 @@ export type WriteResult<R> =
| { ok: true; flowRun: FlowRun; result: R; persisted: boolean }
| { ok: false; code: "PERSIST_CONFLICT" | "READ_FAILED"; message: string }

// ─── Keyed Mutex ───

/**
* 按 key 隔离的互斥锁,用于单进程内串行化并发操作。
*
* 每个 key(parentIssueNumber)维护独立的 promise 链,
* 相同 key 的操作依次执行,不同 key 的操作可以并行。
*/
class KeyedMutex {
private locks = new Map<number, Promise<void>>()

async runExclusive<T>(key: number, fn: () => Promise<T>): Promise<T> {
const prev = this.locks.get(key) ?? Promise.resolve()
let release: () => void
const next = new Promise<void>(r => {
release = r
})
this.locks.set(key, next)

await prev
try {
return await fn()
} finally {
release!()
}
}
}

// ─── FlowBroker ───

/**
Expand Down
72 changes: 72 additions & 0 deletions test/kernel/mutex.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, it, expect } from "vitest"
import { KeyedMutex } from "../../src/kernel/mutex.js"

const tick = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))

describe("KeyedMutex", () => {
it("serializes operations with the same key", async () => {
const mutex = new KeyedMutex()
let inFlight = 0
let maxInFlight = 0

const op = async () => {
inFlight += 1
maxInFlight = Math.max(maxInFlight, inFlight)
await tick(10)
inFlight -= 1
}

await Promise.all([mutex.runExclusive("k", op), mutex.runExclusive("k", op), mutex.runExclusive("k", op)])
expect(maxInFlight).toBe(1)
})

it("runs operations with different keys in parallel", async () => {
const mutex = new KeyedMutex()
let inFlight = 0
let maxInFlight = 0

const op = async () => {
inFlight += 1
maxInFlight = Math.max(maxInFlight, inFlight)
await tick(10)
inFlight -= 1
}

await Promise.all([mutex.runExclusive("a", op), mutex.runExclusive("b", op), mutex.runExclusive("c", op)])
expect(maxInFlight).toBe(3)
})

it("returns the operation result", async () => {
const mutex = new KeyedMutex()
const result = await mutex.runExclusive("k", async () => 42)
expect(result).toBe(42)
})

it("propagates errors and keeps the mutex usable afterwards", async () => {
const mutex = new KeyedMutex()
await expect(
mutex.runExclusive("k", async () => {
throw new Error("boom")
}),
).rejects.toThrow("boom")

const result = await mutex.runExclusive("k", async () => "ok")
expect(result).toBe("ok")
})

it("supports numeric keys", async () => {
const mutex = new KeyedMutex()
let inFlight = 0
let maxInFlight = 0

const op = async () => {
inFlight += 1
maxInFlight = Math.max(maxInFlight, inFlight)
await tick(5)
inFlight -= 1
}

await Promise.all([mutex.runExclusive(1, op), mutex.runExclusive(1, op)])
expect(maxInFlight).toBe(1)
})
})
90 changes: 90 additions & 0 deletions test/kernel/slug.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, it, expect } from "vitest"
import { validateTitle, deriveSlug, branchFor, worktreeFor } from "../../src/kernel/slug.js"

describe("validateTitle", () => {
it("accepts a valid functional slug", () => {
expect(validateTitle("validate-execution-binding")).toEqual({
ok: true,
slug: "validate-execution-binding",
})
})

it("accepts multi-word functional slugs with domain words", () => {
expect(validateTitle("onboarding-checklist").ok).toBe(true)
expect(validateTitle("v2-migration").ok).toBe(true)
})

it.each([
"task-001",
"feature-1",
"implement-task",
"fix-bug",
"add-docs",
"task",
"123",
])("rejects generic title %s", (title) => {
const result = validateTitle(title)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.code).toBe("GENERIC_TITLE")
}
})

it("rejects empty or whitespace-only title", () => {
expect(validateTitle("").ok).toBe(false)
expect(validateTitle(" ").ok).toBe(false)
})

it.each([
"Task 001",
"Validate-execution-binding",
"validate_Execution",
"validate--execution",
"-validate",
"validate-",
"validate binding",
"validate.execution",
])("rejects invalid format %s", (title) => {
expect(validateTitle(title).ok).toBe(false)
})

it("rejects slug longer than 60 characters", () => {
expect(validateTitle("a".repeat(61)).ok).toBe(false)
})

it("accepts slug of exactly 60 characters", () => {
expect(validateTitle("a".repeat(60)).ok).toBe(true)
})
})

describe("deriveSlug", () => {
it("derives kebab slug from a natural title", () => {
expect(deriveSlug("Validate Execution Binding")).toBe("validate-execution-binding")
})

it("collapses separators and strips leading/trailing dashes", () => {
expect(deriveSlug(" Fix -- Message__Ordering ")).toBe("fix-message-ordering")
})

it("returns empty string for meaningless input instead of a fallback", () => {
expect(deriveSlug("!!!")).toBe("")
expect(deriveSlug("")).toBe("")
})

it("truncates over-long slugs to at most 60 characters", () => {
expect(deriveSlug("a".repeat(80)).length).toBeLessThanOrEqual(60)
})
})

describe("branchFor / worktreeFor", () => {
it("builds branch names with the type prefix", () => {
expect(branchFor("validate-execution-binding", "feat")).toBe("feat/validate-execution-binding")
expect(branchFor("validate-execution-binding", "fix")).toBe("fix/validate-execution-binding")
expect(branchFor("validate-execution-binding", "chore")).toBe("chore/validate-execution-binding")
expect(branchFor("validate-execution-binding", "docs")).toBe("docs/validate-execution-binding")
})

it("builds the worktree path", () => {
expect(worktreeFor("validate-execution-binding")).toBe(".worktree/validate-execution-binding")
})
})
Loading