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
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,11 @@ export default function OrchestratorMissionControl({
const directorRepo = directorWorktree ? reposById.get(directorWorktree.repoId) : undefined
const directorPath = directorWorktree?.path ?? null
const directorConnectionId = directorRepo?.connectionId ?? undefined
// Why: the log is committed, so a new director worktree inherits prior
// sessions' outcomes. Scope Shipped to outcomes logged at/after this
// director's creation so foreign work doesn't leak in. Absent for worktrees
// discovered on disk → unscoped (legacy behavior, no createdAt to anchor to).
const directorCreatedAt = directorWorktree?.createdAt
const [shippedItems, setShippedItems] = useState<ShippedWorkItem[]>([])
useEffect(() => {
if (!directorPath) {
Expand All @@ -143,7 +148,7 @@ export default function OrchestratorMissionControl({
.readFile({ filePath, connectionId: directorConnectionId })
.then((result) => {
if (!cancelled) {
setShippedItems(parseOrchestrateLogOutcomes(result.content))
setShippedItems(parseOrchestrateLogOutcomes(result.content, directorCreatedAt))
}
})
.catch(() => {
Expand All @@ -155,7 +160,7 @@ export default function OrchestratorMissionControl({
return () => {
cancelled = true
}
}, [directorPath, directorConnectionId, worktreesByRepo])
}, [directorPath, directorConnectionId, directorCreatedAt, worktreesByRepo])

// Why: resolve the director repo's `owner/repo` so a shipped branch can link to
// its merged PR via GitHub head-ref search — reliable even after the branch is
Expand Down
53 changes: 53 additions & 0 deletions src/renderer/src/lib/orcastrate-log-shipped-work.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,59 @@ describe('parseOrchestrateLogOutcomes', () => {
})
})

describe('parseOrchestrateLogOutcomes lifetime scoping', () => {
// Why: the log is committed, so a new director inherits prior sessions'
// outcomes. `sinceMs` scopes Shipped to the director's own lifetime.
const SINCE = Date.parse('2026-06-21T00:00:00Z')
const SCOPED_LOG = [
JSON.stringify({
type: 'plan',
id: 'p-old',
worktrees: [{ name: 'feat/old-shipped', becomes_pr: 'Prior session work' }]
}),
JSON.stringify({
type: 'outcome',
plan_id: 'p-old',
ts: '2026-06-20T22:41:09Z', // before SINCE → inherited, excluded
results: [{ name: 'feat/old-shipped', tag: 'shipped' }]
}),
JSON.stringify({
type: 'outcome',
plan_id: 'p-new',
ts: '2026-06-21T10:15:00Z', // at/after SINCE → this director's own work
results: [{ name: 'feat/new-shipped', tag: 'shipped' }]
})
].join('\n')

it('keeps only outcomes logged at/after sinceMs', () => {
const items = parseOrchestrateLogOutcomes(SCOPED_LOG, SINCE)
expect(items.map((item) => item.name)).toEqual(['feat/new-shipped'])
expect(selectShippedWork(items).map((item) => item.name)).toEqual(['feat/new-shipped'])
})

it('returns empty for a brand-new director over an old-only log', () => {
const NOW = Date.parse('2026-06-24T00:00:00Z')
expect(parseOrchestrateLogOutcomes(SCOPED_LOG, NOW)).toEqual([])
})

it('excludes outcomes with a missing or unparseable ts when scoping', () => {
const log = [
JSON.stringify({ type: 'outcome', results: [{ name: 'no-ts', tag: 'shipped' }] }),
JSON.stringify({
type: 'outcome',
ts: 'not-a-date',
results: [{ name: 'bad-ts', tag: 'shipped' }]
})
].join('\n')
expect(parseOrchestrateLogOutcomes(log, SINCE)).toEqual([])
})

it('is unaffected by ts when sinceMs is omitted (legacy callers)', () => {
const items = parseOrchestrateLogOutcomes(SCOPED_LOG)
expect(items.map((item) => item.name)).toEqual(['feat/old-shipped', 'feat/new-shipped'])
})
})

describe('selectShippedWork', () => {
it('keeps only shipped outcomes', () => {
const shipped = selectShippedWork(parseOrchestrateLogOutcomes(LOG))
Expand Down
26 changes: 25 additions & 1 deletion src/renderer/src/lib/orcastrate-log-shipped-work.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,33 @@ function asRecord(value: unknown): UnknownRecord | null {
return value && typeof value === 'object' ? (value as UnknownRecord) : null
}

/**
* True when an outcome's `ts` parses to a time at/after `sinceMs`. A missing or
* unparseable `ts` returns false so the outcome is excluded — it can't be
* attributed to the current director's lifetime.
*/
function isOutcomeWithinLifetime(ts: unknown, sinceMs: number): boolean {
if (typeof ts !== 'string') {
return false
}
const parsed = Date.parse(ts)
return !Number.isNaN(parsed) && parsed >= sinceMs
}

/**
* Parse a director's `.orcastrate/log.jsonl` into its per-worktree outcomes,
* joining each outcome to its plan description by name. Latest outcome per name
* wins; unparseable lines are skipped. Order follows first appearance.
*
* When `sinceMs` is set, only outcome records whose `ts` parses to a time
* at/after `sinceMs` contribute. Why: the log is committed to the repo, so a
* new director worktree (branched from `main`) inherits every prior session's
* outcomes. Scoping to the director's own lifetime keeps foreign work out of
* its Shipped view. An outcome with a missing/unparseable `ts` can't be
* attributed to this director, so it's excluded too (err toward not showing
* foreign work). Plan records are unscoped — they only supply descriptions.
*/
export function parseOrchestrateLogOutcomes(logText: string): ShippedWorkItem[] {
export function parseOrchestrateLogOutcomes(logText: string, sinceMs?: number): ShippedWorkItem[] {
const descriptionByName = new Map<string, string>()
const tagByName = new Map<string, string>()
const order: string[] = []
Expand Down Expand Up @@ -65,6 +86,9 @@ export function parseOrchestrateLogOutcomes(logText: string): ShippedWorkItem[]
}
}
} else if (record.type === 'outcome' && Array.isArray(record.results)) {
if (sinceMs !== undefined && !isOutcomeWithinLifetime(record.ts, sinceMs)) {
continue
}
for (const raw of record.results) {
const result = asRecord(raw)
const name = result?.name
Expand Down