@hueyexe/opencode-ensemble 0.14.2 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -6
- package/dist/index.js +2128 -884
- package/package.json +1 -1
- package/src/dashboard-html.ts +11 -3
- package/src/{dashboard-js-part1.ts → dashboard-js-core.ts} +11 -3
- package/src/{dashboard-js-part3.ts → dashboard-js-events.ts} +25 -4
- package/src/{dashboard-js-part2.ts → dashboard-js-render.ts} +13 -8
- package/src/dashboard.ts +62 -21
- package/src/db.ts +2 -3
- package/src/index.ts +10 -11
- package/src/recovery.ts +26 -22
- package/src/schema.ts +122 -2
- package/src/tools/merge-helper.ts +46 -2
- package/src/tools/team-cleanup.ts +29 -14
- package/src/tools/team-create.ts +17 -5
- package/src/tools/team-shutdown.ts +8 -7
- package/src/tools/team-spawn.ts +3 -1
- package/src/util.ts +17 -0
- package/src/watchdog.ts +4 -3
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Database } from "../db"
|
|
1
2
|
import { log } from "../log"
|
|
2
3
|
import { runCommand } from "../process"
|
|
3
4
|
|
|
@@ -19,6 +20,49 @@ export type PreserveBranchFn = (sourceBranch: string, targetBranch: string, cwd:
|
|
|
19
20
|
/** Injectable function for deleting a branch. */
|
|
20
21
|
export type DeleteBranchFn = (branch: string, cwd: string) => Promise<boolean>
|
|
21
22
|
|
|
23
|
+
/** Human-readable resource identity for a team. */
|
|
24
|
+
export interface TeamResourceParts {
|
|
25
|
+
projectName: string
|
|
26
|
+
teamName: string
|
|
27
|
+
teamId: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function shortTeamId(teamId: string): string {
|
|
31
|
+
return (teamId.split("_").at(-1) || teamId).slice(0, 6)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function resourcePart(value: string): string {
|
|
35
|
+
const part = value.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "")
|
|
36
|
+
return part || "unnamed"
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Load the human-readable pieces used to build team resource names. */
|
|
40
|
+
export function getTeamResourceParts(db: Database, teamId: string): TeamResourceParts {
|
|
41
|
+
const row = db.query(
|
|
42
|
+
`SELECT t.id as team_id, t.name as team_name, p.name as project_name
|
|
43
|
+
FROM team t
|
|
44
|
+
JOIN project p ON t.project_id = p.id
|
|
45
|
+
WHERE t.id = ?`
|
|
46
|
+
).get(teamId) as { team_id: string; team_name: string; project_name: string } | null
|
|
47
|
+
if (!row) throw new Error(`Team not found: ${teamId}`)
|
|
48
|
+
return { projectName: row.project_name, teamName: row.team_name, teamId: row.team_id }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Build the readable namespace used for team-owned resources. */
|
|
52
|
+
export function teamResourceSlug(projectName: string, teamName: string, teamId: string): string {
|
|
53
|
+
return `${resourcePart(projectName)}-${resourcePart(teamName)}#${shortTeamId(teamId)}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Build the team-only resource segment used under project-scoped namespaces. */
|
|
57
|
+
export function teamResourceSegment(teamName: string, teamId: string): string {
|
|
58
|
+
return `${resourcePart(teamName)}#${shortTeamId(teamId)}`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Build an OpenCode worktree name for a team member. */
|
|
62
|
+
export function teamWorktreeName(projectName: string, teamName: string, teamId: string, memberName: string): string {
|
|
63
|
+
return `ensemble-${teamResourceSlug(projectName, teamName, teamId)}-${memberName}`
|
|
64
|
+
}
|
|
65
|
+
|
|
22
66
|
/**
|
|
23
67
|
* Copy a git branch to a new ref. Used to preserve worktree branches
|
|
24
68
|
* before session.abort() which may delete the worktree and its branch.
|
|
@@ -99,6 +143,6 @@ export async function getOverlappingFiles(branch: string, cwd: string): Promise<
|
|
|
99
143
|
/**
|
|
100
144
|
* Build the preserved branch name for a team member.
|
|
101
145
|
*/
|
|
102
|
-
export function preservedBranchName(teamName: string, memberName: string): string {
|
|
103
|
-
return `ensemble/preserved/${teamName}/${memberName}`
|
|
146
|
+
export function preservedBranchName(projectName: string, teamName: string, teamId: string, memberName: string): string {
|
|
147
|
+
return `ensemble/preserved/${resourcePart(projectName)}/${teamResourceSegment(teamName, teamId)}/${resourcePart(memberName)}`
|
|
104
148
|
}
|
|
@@ -2,18 +2,20 @@ import type { ToolDeps } from "../types"
|
|
|
2
2
|
import { requireLead, requireCanPurgeArchivedTeams, checkWorktreeDirty } from "./shared"
|
|
3
3
|
import type { IsDirtyFn } from "./shared"
|
|
4
4
|
import { spawnFailures } from "./team-spawn"
|
|
5
|
-
import { mergeBranch, deleteBranch, preserveBranch, preservedBranchName, getOverlappingFiles } from "./merge-helper"
|
|
5
|
+
import { getTeamResourceParts, mergeBranch, deleteBranch, preserveBranch, preservedBranchName, getOverlappingFiles, teamResourceSegment } from "./merge-helper"
|
|
6
6
|
import type { MergeBranchFn, DeleteBranchFn, PreserveBranchFn, OverlapCheckFn } from "./merge-helper"
|
|
7
7
|
import { log } from "../log"
|
|
8
8
|
import { runCommand } from "../process"
|
|
9
9
|
|
|
10
10
|
type PurgeApprovalFn = (preview: string) => Promise<void>
|
|
11
|
-
type ListBranchesFn = (
|
|
11
|
+
type ListBranchesFn = (namespace: string, cwd: string) => Promise<string[]>
|
|
12
12
|
type BranchExistsFn = (branch: string, cwd: string) => Promise<boolean>
|
|
13
13
|
|
|
14
14
|
interface PurgeTarget {
|
|
15
15
|
id: string
|
|
16
16
|
name: string
|
|
17
|
+
project_id: string
|
|
18
|
+
project_name: string
|
|
17
19
|
time_updated: number
|
|
18
20
|
}
|
|
19
21
|
|
|
@@ -29,6 +31,7 @@ interface PurgeStats extends PurgeTarget {
|
|
|
29
31
|
interface PurgeMemberResource {
|
|
30
32
|
team_id: string
|
|
31
33
|
team_name: string
|
|
34
|
+
project_name: string
|
|
32
35
|
member_name: string
|
|
33
36
|
worktree_dir: string | null
|
|
34
37
|
workspace_id: string | null
|
|
@@ -73,24 +76,25 @@ function resolvePurgeTargets(deps: ToolDeps, purge: string[]): PurgeTarget[] {
|
|
|
73
76
|
}
|
|
74
77
|
|
|
75
78
|
if (purge.includes("*")) {
|
|
76
|
-
return deps.db.query("SELECT id, name, time_updated FROM team WHERE status = 'archived' ORDER BY time_updated DESC, name ASC")
|
|
77
|
-
.all() as PurgeTarget[]
|
|
79
|
+
return deps.db.query("SELECT t.id, t.name, t.project_id, p.name as project_name, t.time_updated FROM team t JOIN project p ON t.project_id = p.id WHERE t.status = 'archived' AND t.project_id = ? ORDER BY t.time_updated DESC, t.name ASC")
|
|
80
|
+
.all(deps.directory) as PurgeTarget[]
|
|
78
81
|
}
|
|
79
82
|
|
|
80
83
|
const uniqueNames = [...new Set(purge)]
|
|
81
84
|
const rows = uniqueNames.map(name => ({
|
|
82
85
|
name,
|
|
83
|
-
|
|
86
|
+
teams: deps.db.query("SELECT t.id, t.name, t.project_id, p.name as project_name, t.status, t.time_updated FROM team t JOIN project p ON t.project_id = p.id WHERE t.name = ? AND t.project_id = ? ORDER BY t.time_updated DESC")
|
|
87
|
+
.all(name, deps.directory) as Array<{ id: string; name: string; project_id: string; project_name: string; status: string; time_updated: number }>,
|
|
84
88
|
}))
|
|
85
89
|
|
|
86
|
-
const missing = rows.filter(row => row.
|
|
90
|
+
const missing = rows.filter(row => row.teams.length === 0).map(row => row.name)
|
|
87
91
|
if (missing.length > 0) throw new Error(`Team not found: ${missing.join(", ")}`)
|
|
88
92
|
|
|
89
|
-
const active = rows.filter(row => row.team
|
|
93
|
+
const active = rows.filter(row => row.teams.some(team => team.status === "active")).map(row => row.name)
|
|
90
94
|
if (active.length > 0) throw new Error(`Cannot purge active team: ${active.join(", ")}`)
|
|
91
95
|
|
|
92
96
|
return rows
|
|
93
|
-
.map(row => row.
|
|
97
|
+
.map(row => row.teams[0]!)
|
|
94
98
|
.sort((a, b) => b.time_updated - a.time_updated || a.name.localeCompare(b.name))
|
|
95
99
|
}
|
|
96
100
|
|
|
@@ -116,6 +120,7 @@ function deleteArchivedTeams(deps: ToolDeps, targets: PurgeTarget[]): void {
|
|
|
116
120
|
function getPurgeMemberResources(deps: ToolDeps, targets: PurgeTarget[]): PurgeMemberResource[] {
|
|
117
121
|
return targets.flatMap(target => deps.db.query(
|
|
118
122
|
`SELECT t.name as team_name,
|
|
123
|
+
p.name as project_name,
|
|
119
124
|
tm.team_id,
|
|
120
125
|
tm.name as member_name,
|
|
121
126
|
tm.worktree_dir,
|
|
@@ -123,23 +128,32 @@ function getPurgeMemberResources(deps: ToolDeps, targets: PurgeTarget[]): PurgeM
|
|
|
123
128
|
tm.worktree_branch
|
|
124
129
|
FROM team_member tm
|
|
125
130
|
JOIN team t ON tm.team_id = t.id
|
|
131
|
+
JOIN project p ON t.project_id = p.id
|
|
126
132
|
WHERE tm.team_id = ?`
|
|
127
133
|
).all(target.id) as PurgeMemberResource[])
|
|
128
134
|
}
|
|
129
135
|
|
|
130
136
|
function preservedBranchPrefix(resource: PurgeMemberResource): string {
|
|
137
|
+
return `ensemble/preserved/${resource.project_name}/${teamResourceSegment(resource.team_name, resource.team_id)}/`
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function legacyPreservedBranchPrefix(resource: PurgeMemberResource): string {
|
|
131
141
|
return `ensemble/preserved/${resource.team_name}/`
|
|
132
142
|
}
|
|
133
143
|
|
|
134
144
|
function staleEnsembleBranchNames(resource: PurgeMemberResource): string[] {
|
|
135
145
|
return [
|
|
146
|
+
`ensemble-${resource.team_id}-${resource.member_name}`,
|
|
136
147
|
`ensemble-${resource.team_name}-${resource.member_name}`,
|
|
137
148
|
`opencode/ensemble-${resource.team_name}-${resource.member_name}`,
|
|
138
149
|
]
|
|
139
150
|
}
|
|
140
151
|
|
|
141
152
|
function isPreservedBranch(resource: PurgeMemberResource): boolean {
|
|
142
|
-
return resource.worktree_branch !== null &&
|
|
153
|
+
return resource.worktree_branch !== null && (
|
|
154
|
+
resource.worktree_branch.startsWith(preservedBranchPrefix(resource)) ||
|
|
155
|
+
resource.worktree_branch.startsWith(legacyPreservedBranchPrefix(resource))
|
|
156
|
+
)
|
|
143
157
|
}
|
|
144
158
|
|
|
145
159
|
function isStaleEnsembleBranch(resource: PurgeMemberResource): boolean {
|
|
@@ -300,16 +314,16 @@ async function collectPreservedBranches(
|
|
|
300
314
|
listBranches: ListBranchesFn,
|
|
301
315
|
): Promise<Map<string, string[]>> {
|
|
302
316
|
const entries: Array<[string, string[]]> = await Promise.all(targets.map(async target => {
|
|
303
|
-
const
|
|
317
|
+
const prefixes = [`ensemble/preserved/${target.project_name}/${teamResourceSegment(target.name, target.id)}/`, `ensemble/preserved/${target.name}/`]
|
|
304
318
|
let listed: string[]
|
|
305
319
|
try {
|
|
306
|
-
listed = await listBranches(target.name, cwd)
|
|
320
|
+
listed = [...await listBranches(target.project_name, cwd), ...await listBranches(target.name, cwd)]
|
|
307
321
|
} catch (err) {
|
|
308
322
|
const message = err instanceof Error ? err.message : String(err)
|
|
309
323
|
if (message.includes("not a git repository")) listed = []
|
|
310
|
-
else throw new Error(`Failed to list preserved branches for ${target.
|
|
324
|
+
else throw new Error(`Failed to list preserved branches for ${target.id}: ${message}`)
|
|
311
325
|
}
|
|
312
|
-
const branches = listed.map(normalizeBranchName).filter(branch => branch.startsWith(prefix))
|
|
326
|
+
const branches = listed.map(normalizeBranchName).filter(branch => prefixes.some(prefix => branch.startsWith(prefix)))
|
|
313
327
|
return [target.id, [...new Set(branches)]]
|
|
314
328
|
}))
|
|
315
329
|
return new Map(entries)
|
|
@@ -473,7 +487,8 @@ export async function executeTeamCleanup(
|
|
|
473
487
|
for (const member of active) {
|
|
474
488
|
// Preserve branch before abort — session.abort() may destroy the worktree + branch
|
|
475
489
|
if (member.worktree_branch && !member.worktree_branch.startsWith("ensemble/preserved/")) {
|
|
476
|
-
const
|
|
490
|
+
const resource = getTeamResourceParts(deps.db, teamInfo.teamId)
|
|
491
|
+
const safeBranch = preservedBranchName(resource.projectName, resource.teamName, resource.teamId, member.name)
|
|
477
492
|
const ok = await preserveBranch(member.worktree_branch, safeBranch, deps.directory)
|
|
478
493
|
if (ok) {
|
|
479
494
|
deps.db.run("UPDATE team_member SET worktree_branch = ? WHERE team_id = ? AND name = ?",
|
package/src/tools/team-create.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
|
-
import { generateId, validateTeamName } from "../util"
|
|
2
|
+
import { generateId, generateProjectName, validateProjectName, validateTeamName } from "../util"
|
|
3
3
|
import { findTeamBySession } from "../types"
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -7,14 +7,19 @@ import { findTeamBySession } from "../types"
|
|
|
7
7
|
*/
|
|
8
8
|
export async function executeTeamCreate(
|
|
9
9
|
deps: ToolDeps,
|
|
10
|
-
args: { name: string },
|
|
10
|
+
args: { name: string; project_name?: string },
|
|
11
11
|
sessionId: string,
|
|
12
12
|
): Promise<string> {
|
|
13
13
|
const nameError = validateTeamName(args.name)
|
|
14
14
|
if (nameError) throw new Error(nameError)
|
|
15
|
+
if (args.project_name) {
|
|
16
|
+
const projectNameError = validateProjectName(args.project_name)
|
|
17
|
+
if (projectNameError) throw new Error(projectNameError)
|
|
18
|
+
}
|
|
15
19
|
|
|
16
20
|
// Check if team name already exists
|
|
17
|
-
const
|
|
21
|
+
const projectId = deps.directory
|
|
22
|
+
const existing = deps.db.query("SELECT id FROM team WHERE name = ? AND project_id = ? AND status = 'active'").get(args.name, projectId)
|
|
18
23
|
if (existing) throw new Error(`Team "${args.name}" already exists`)
|
|
19
24
|
|
|
20
25
|
// Check if session already leads a team
|
|
@@ -23,9 +28,16 @@ export async function executeTeamCreate(
|
|
|
23
28
|
|
|
24
29
|
const id = generateId("team")
|
|
25
30
|
const now = Date.now()
|
|
31
|
+
const projectName = args.project_name ?? generateProjectName()
|
|
26
32
|
deps.db.run(
|
|
27
|
-
|
|
28
|
-
|
|
33
|
+
`INSERT INTO project (id, name, path, status, time_created, time_updated)
|
|
34
|
+
VALUES (?, ?, ?, 'active', ?, ?)
|
|
35
|
+
ON CONFLICT(id) DO UPDATE SET time_updated = excluded.time_updated`,
|
|
36
|
+
[projectId, projectName, projectId, now, now]
|
|
37
|
+
)
|
|
38
|
+
deps.db.run(
|
|
39
|
+
"INSERT INTO team (id, name, project_id, lead_session_id, status, delegate, time_created, time_updated) VALUES (?, ?, ?, ?, 'active', 0, ?, ?)",
|
|
40
|
+
[id, args.name, projectId, sessionId, now, now]
|
|
29
41
|
)
|
|
30
42
|
|
|
31
43
|
return `Team "${args.name}" created. You are the lead. Use team_spawn to add teammates.`
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
2
|
import { requireLead, checkWorktreeDirty, countBranchCommits } from "./shared"
|
|
3
3
|
import type { IsDirtyFn, CommitCountFn } from "./shared"
|
|
4
|
-
import { preserveBranch, preservedBranchName } from "./merge-helper"
|
|
4
|
+
import { getTeamResourceParts, preserveBranch, preservedBranchName } from "./merge-helper"
|
|
5
5
|
import type { PreserveBranchFn } from "./merge-helper"
|
|
6
6
|
import { log } from "../log"
|
|
7
7
|
|
|
@@ -30,7 +30,7 @@ export async function executeTeamShutdown(
|
|
|
30
30
|
|
|
31
31
|
// Second call on an already-requested member → force abort
|
|
32
32
|
if (member.status === "shutdown_requested") {
|
|
33
|
-
await preserveAndAbort(deps, teamInfo.teamId,
|
|
33
|
+
await preserveAndAbort(deps, teamInfo.teamId, args.member, member.session_id, member.worktree_branch, preserve)
|
|
34
34
|
const status = await getBranchStatus(deps, teamInfo.teamId, args.member, member.worktree_dir, isDirty, commitCount)
|
|
35
35
|
return `Force shut down "${args.member}".${status}`
|
|
36
36
|
}
|
|
@@ -46,7 +46,7 @@ export async function executeTeamShutdown(
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
if (isIdle || force) {
|
|
49
|
-
await preserveAndAbort(deps, teamInfo.teamId,
|
|
49
|
+
await preserveAndAbort(deps, teamInfo.teamId, args.member, member.session_id, member.worktree_branch, preserve)
|
|
50
50
|
const status = await getBranchStatus(deps, teamInfo.teamId, args.member, member.worktree_dir, isDirty, commitCount)
|
|
51
51
|
return `Teammate "${args.member}" has been shut down.${status}`
|
|
52
52
|
}
|
|
@@ -55,7 +55,8 @@ export async function executeTeamShutdown(
|
|
|
55
55
|
// Branch must be preserved NOW — if the session crashes during shutdown_requested,
|
|
56
56
|
// the worktree and branch could be lost before force-abort ever runs.
|
|
57
57
|
if (member.worktree_branch) {
|
|
58
|
-
const
|
|
58
|
+
const resource = getTeamResourceParts(deps.db, teamInfo.teamId)
|
|
59
|
+
const safeBranch = preservedBranchName(resource.projectName, resource.teamName, resource.teamId, args.member)
|
|
59
60
|
const ok = await preserve(member.worktree_branch, safeBranch, deps.directory)
|
|
60
61
|
if (ok) {
|
|
61
62
|
deps.db.run(
|
|
@@ -88,13 +89,12 @@ export async function executeTeamShutdown(
|
|
|
88
89
|
|
|
89
90
|
/**
|
|
90
91
|
* Preserve the worktree branch, then abort the session and mark shutdown.
|
|
91
|
-
* The branch is copied to ensemble/preserved/{
|
|
92
|
+
* The branch is copied to ensemble/preserved/{team_id}/{name} BEFORE abort,
|
|
92
93
|
* so session.abort() cannot destroy the agent's committed work.
|
|
93
94
|
*/
|
|
94
95
|
async function preserveAndAbort(
|
|
95
96
|
deps: ToolDeps,
|
|
96
97
|
teamId: string,
|
|
97
|
-
teamName: string,
|
|
98
98
|
memberName: string,
|
|
99
99
|
sessionId: string,
|
|
100
100
|
worktreeBranch: string | null,
|
|
@@ -102,7 +102,8 @@ async function preserveAndAbort(
|
|
|
102
102
|
): Promise<void> {
|
|
103
103
|
// Preserve the branch BEFORE aborting — session.abort() may delete the worktree + branch
|
|
104
104
|
if (worktreeBranch && !worktreeBranch.startsWith("ensemble/preserved/")) {
|
|
105
|
-
const
|
|
105
|
+
const resource = getTeamResourceParts(deps.db, teamId)
|
|
106
|
+
const safeBranch = preservedBranchName(resource.projectName, resource.teamName, resource.teamId, memberName)
|
|
106
107
|
const ok = await preserve(worktreeBranch, safeBranch, deps.directory)
|
|
107
108
|
if (ok) {
|
|
108
109
|
deps.db.run(
|
package/src/tools/team-spawn.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { requireLead } from "./shared"
|
|
|
4
4
|
import { sendMessage } from "../messaging"
|
|
5
5
|
import { log } from "../log"
|
|
6
6
|
import type { EnsembleConfig } from "../config"
|
|
7
|
+
import { getTeamResourceParts, teamWorktreeName } from "./merge-helper"
|
|
7
8
|
|
|
8
9
|
/** Tracks consecutive spawn failures per team for circuit breaker. */
|
|
9
10
|
export const spawnFailures = new Map<string, { count: number; lastError: string }>()
|
|
@@ -96,7 +97,8 @@ export async function executeTeamSpawn(
|
|
|
96
97
|
let worktreeBranch: string | null = null
|
|
97
98
|
|
|
98
99
|
if (useWorktree) {
|
|
99
|
-
const
|
|
100
|
+
const resource = getTeamResourceParts(deps.db, teamInfo.teamId)
|
|
101
|
+
const worktreeName = teamWorktreeName(resource.projectName, resource.teamName, resource.teamId, args.name)
|
|
100
102
|
try {
|
|
101
103
|
log(`spawn:worktree:start name=${args.name}`)
|
|
102
104
|
const result = await withTimeout(
|
package/src/util.ts
CHANGED
|
@@ -23,6 +23,23 @@ export function validateTeamName(name: string): string | undefined {
|
|
|
23
23
|
return undefined
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
const PROJECT_ADJECTIVES = ["amber", "brave", "calm", "copper", "crisp", "dark", "ember", "frost", "quiet", "silver", "steady", "swift"]
|
|
27
|
+
const PROJECT_NOUNS = ["atlas", "beacon", "comet", "harbor", "lab", "orbit", "river", "signal", "sparrow", "summit", "vector", "voyage"]
|
|
28
|
+
|
|
29
|
+
/** Generate a short human-readable project name for implicit project creation. */
|
|
30
|
+
export function generateProjectName(): string {
|
|
31
|
+
const adjective = PROJECT_ADJECTIVES[Math.floor(Math.random() * PROJECT_ADJECTIVES.length)] ?? "quiet"
|
|
32
|
+
const noun = PROJECT_NOUNS[Math.floor(Math.random() * PROJECT_NOUNS.length)] ?? "project"
|
|
33
|
+
return `${adjective}-${noun}`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Validate a project display name. Returns an error if invalid. */
|
|
37
|
+
export function validateProjectName(name: string): string | undefined {
|
|
38
|
+
if (name.length < 1 || name.length > 64) return "Project name must be 1-64 characters"
|
|
39
|
+
if (!NAME_PATTERN.test(name)) return "Project name must be lowercase alphanumeric with hyphens only"
|
|
40
|
+
return undefined
|
|
41
|
+
}
|
|
42
|
+
|
|
26
43
|
/**
|
|
27
44
|
* Detect if the plugin is loading inside a teammate's worktree instance.
|
|
28
45
|
* Worktree instances should skip recovery to avoid deadlocking the server.
|
package/src/watchdog.ts
CHANGED
|
@@ -185,18 +185,19 @@ export class Watchdog {
|
|
|
185
185
|
|
|
186
186
|
const cutoff = Date.now() - this.ttlMs
|
|
187
187
|
const stale = this.db.query(
|
|
188
|
-
`SELECT tm.team_id, tm.name, tm.session_id, tm.worktree_branch, t.name as team_name
|
|
188
|
+
`SELECT tm.team_id, tm.name, tm.session_id, tm.worktree_branch, t.name as team_name, p.name as project_name
|
|
189
189
|
FROM team_member tm
|
|
190
190
|
JOIN team t ON tm.team_id = t.id
|
|
191
|
+
JOIN project p ON t.project_id = p.id
|
|
191
192
|
WHERE t.status = 'active'
|
|
192
193
|
AND tm.status = 'busy'
|
|
193
194
|
AND tm.time_updated < ?`
|
|
194
|
-
).all(cutoff) as Array<{ team_id: string; name: string; session_id: string; worktree_branch: string | null; team_name: string }>
|
|
195
|
+
).all(cutoff) as Array<{ team_id: string; name: string; session_id: string; worktree_branch: string | null; team_name: string; project_name: string }>
|
|
195
196
|
|
|
196
197
|
for (const member of stale) {
|
|
197
198
|
// Preserve branch BEFORE abort — session.abort() may destroy the worktree + branch
|
|
198
199
|
if (this.cwd && member.worktree_branch && !member.worktree_branch.startsWith("ensemble/preserved/")) {
|
|
199
|
-
const safeBranch = preservedBranchName(member.team_name, member.name)
|
|
200
|
+
const safeBranch = preservedBranchName(member.project_name, member.team_name, member.team_id, member.name)
|
|
200
201
|
const ok = await preserveBranch(member.worktree_branch, safeBranch, this.cwd)
|
|
201
202
|
if (ok) {
|
|
202
203
|
this.db.run("UPDATE team_member SET worktree_branch = ? WHERE team_id = ? AND name = ?",
|