Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
06f70a0
refactor(canvas): establish TypeScript extension foundation
lukasoppermann Oct 21, 2026
edfef38
refactor(canvas): migrate shared extension infrastructure
lukasoppermann Oct 21, 2026
c1869b9
refactor(canvas): migrate Hub connection modules to TypeScript
lukasoppermann Oct 21, 2026
eb94b7d
refactor(canvas): type publication and state orchestration
lukasoppermann Oct 21, 2026
80d9a74
fix(canvas): accept null pairing refresh tokens
lukasoppermann Oct 22, 2026
0130a1e
fix(canvas): validate saved publication evidence
lukasoppermann Oct 22, 2026
87da450
refactor(canvas): migrate Hub connection modules to TypeScript
lukasoppermann Oct 21, 2026
552b466
fix(canvas): accept null pairing refresh tokens
lukasoppermann Oct 22, 2026
373f011
refactor(canvas): type publication and state orchestration
lukasoppermann Oct 21, 2026
4b03275
fix(canvas): validate saved publication evidence
lukasoppermann Oct 22, 2026
e652cca
Merge remote-tracking branch 'origin/lukasoppermann-canvas-typescript…
lukasoppermann Oct 22, 2026
77abc01
Merge main into canvas Hub connection
lukasoppermann Oct 22, 2026
424f989
merge: integrate latest canvas publication head
lukasoppermann Oct 22, 2026
457b508
merge: update canvas publication from hub connection base
lukasoppermann Oct 22, 2026
f7d437c
build(canvas): refresh generated publish target
lukasoppermann Oct 22, 2026
8dcd14c
Merge latest main into canvas Hub connection
lukasoppermann Oct 22, 2026
11891d2
Merge commit '8dcd14c4bcd319a7d5dbb87ccf5c2c7c15c51270' into lukasopp…
lukasoppermann Oct 22, 2026
0dff6e5
merge: sync publication branch with main
lukasoppermann Oct 22, 2026
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
@@ -1,5 +1,5 @@
import { readRepositoryStats } from "../read-repository-stats/index.mjs"
import { readState } from "../read-state/index.mjs"
import { readPublicationState } from "../read-publication-state/index.mjs"
import { writeState } from "../write-state/index.mjs"
import { runCommand } from "../run-command/index.mjs"
import {
Expand All @@ -8,11 +8,10 @@ import {
sameCheckout,
samePublishTarget,
} from "../publish-target/index.mjs"

const refreshes = new Map()

export async function getPrototypeState(context, { refresh = false } = {}) {
if (refreshes.has(context.stateFile)) return refreshes.get(context.stateFile)
const active = refreshes.get(context.stateFile)
if (active) return active
const pending = readCurrentState(context, refresh)
refreshes.set(context.stateFile, pending)
try {
Expand All @@ -21,27 +20,23 @@ export async function getPrototypeState(context, { refresh = false } = {}) {
refreshes.delete(context.stateFile)
}
}

async function readCurrentState(context, refresh) {
let state = await readState(context)
const age = Date.now() - Date.parse(state.stats.refreshedAt)
let state = await readPublicationState(context)
const age = Date.now() - Date.parse(state.stats.refreshedAt ?? "")
if (!refresh) {
if (!Number.isFinite(age)) return presentState(context, state)
const head = await runCommand("git", ["rev-parse", "HEAD"], context.repositoryPath)
if (age < 60_000 && head.ok && head.stdout.trim() === state.stats.commitSha)
return presentState(context, state)
}

const stats = await readRepositoryStats(context)
state = await readState(context)
state = await readPublicationState(context)
state.stats = stats
await synchronizePublishRequest(context, state)
state.updatedAt = new Date().toISOString()
await writeState(context, state)

return presentState(context, state)
}

function presentState(context, state) {
return {
stateId: context.stateId,
Expand All @@ -54,7 +49,6 @@ function presentState(context, state) {
updatedAt: state.updatedAt,
}
}

function resolvePublishStatus(state) {
const workflow = state.stats.workflow
if (state.publishRequest.state === "requested") {
Expand All @@ -69,10 +63,8 @@ function resolvePublishStatus(state) {
detail: state.publishRequest.error || "The publish request failed.",
}
}

return workflow
}

async function synchronizePublishRequest(context, state) {
if (state.publishRequest.state === "requested") {
const currentTarget = await capturePublishTarget(context)
Expand All @@ -90,7 +82,7 @@ async function synchronizePublishRequest(context, state) {
return
}
state.publishRequest.commitSha = currentTarget.commitSha
if (Date.now() - Date.parse(state.publishRequest.requestedAt) >= 10 * 60_000) {
if (Date.now() - Date.parse(state.publishRequest.requestedAt ?? "") >= 10 * 60_000) {
state.publishRequest.state = "failed"
state.publishRequest.error =
"No completed push was observed. Check Copilot chat and retry when ready."
Expand All @@ -117,7 +109,6 @@ async function synchronizePublishRequest(context, state) {
state.publishRequest.error = null
}
}

function observedNewPush(state) {
const request = state.publishRequest
return (
Expand Down
137 changes: 137 additions & 0 deletions .github/extensions/replica-prototype/get-prototype-state/index.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { readRepositoryStats } from "../read-repository-stats/index.mjs"
import { readPublicationState } from "../read-publication-state/index.mjs"
import type { PublicationState } from "../read-publication-state/index.mjs"
import type { CanvasContext } from "../create-canvas-context/index.mjs"
import { writeState } from "../write-state/index.mjs"
import { runCommand } from "../run-command/index.mjs"
import {
capturePublishTarget,
continuesPublishTarget,
sameCheckout,
samePublishTarget,
} from "../publish-target/index.mjs"

export type PrototypeState = Awaited<ReturnType<typeof getPrototypeState>>
const refreshes = new Map<string, Promise<ReturnType<typeof presentState>>>()

export async function getPrototypeState(context: CanvasContext, { refresh = false } = {}) {
const active = refreshes.get(context.stateFile)
if (active) return active
const pending = readCurrentState(context, refresh)
refreshes.set(context.stateFile, pending)
try {
return await pending
} finally {
refreshes.delete(context.stateFile)
}
}

async function readCurrentState(context: CanvasContext, refresh: boolean) {
let state = await readPublicationState(context)
const age = Date.now() - Date.parse(state.stats.refreshedAt ?? "")
if (!refresh) {
if (!Number.isFinite(age)) return presentState(context, state)
const head = await runCommand("git", ["rev-parse", "HEAD"], context.repositoryPath)
if (age < 60_000 && head.ok && head.stdout.trim() === state.stats.commitSha)
return presentState(context, state)
}

const stats = await readRepositoryStats(context)
state = await readPublicationState(context)
state.stats = stats
await synchronizePublishRequest(context, state)
state.updatedAt = new Date().toISOString()
await writeState(context, state)

return presentState(context, state)
}

function presentState(context: CanvasContext, state: PublicationState) {
return {
stateId: context.stateId,
prototypeUrl: context.normalizedUrl,
name: context.name,
sessionId: context.sessionId,
publishRequest: state.publishRequest,
publishStatus: resolvePublishStatus(state),
stats: state.stats,
updatedAt: state.updatedAt,
}
}

function resolvePublishStatus(state: PublicationState) {
const workflow = state.stats.workflow
if (state.publishRequest.state === "requested") {
return {
state: "requested",
detail: "Copilot has the request and will continue in chat.",
}
}
if (state.publishRequest.state === "failed") {
return {
state: "failed",
detail: state.publishRequest.error || "The publish request failed.",
}
}

return workflow
}

async function synchronizePublishRequest(context: CanvasContext, state: PublicationState) {
if (state.publishRequest.state === "requested") {
const currentTarget = await capturePublishTarget(context)
if (currentTarget.commitSha !== state.stats.commitSha)
throw new Error("The checkout changed while refreshing publication status. Refresh again.")
const continuing = state.publishRequest.target
? state.publishRequest.mode === "committed-only"
? samePublishTarget(state.publishRequest.target, currentTarget)
: await continuesPublishTarget(context, state.publishRequest.target, currentTarget)
: state.publishRequest.commitSha === currentTarget.commitSha
if (!continuing) {
state.publishRequest.state = "failed"
state.publishRequest.error =
"The publication target changed. Check Copilot chat and retry on the intended checkout."
return
}
state.publishRequest.commitSha = currentTarget.commitSha
if (Date.now() - Date.parse(state.publishRequest.requestedAt ?? "") >= 10 * 60_000) {
state.publishRequest.state = "failed"
state.publishRequest.error =
"No completed push was observed. Check Copilot chat and retry when ready."
return
} else if (!observedNewPush(state)) {
return
}
}
if (
state.publishRequest.state === "failed" &&
(!state.publishRequest.target ||
sameCheckout(state.publishRequest.target, await capturePublishTarget(context))) &&
observedNewPush(state)
) {
// A later push of the current HEAD supersedes the failed request; its workflow and Build
// evidence now describe publication.
delete state.publishRequest.outcome
delete state.publishRequest.reportedAt
state.publishRequest.state = "idle"
state.publishRequest.error = null
}
if (state.publishRequest.state === "requested") {
state.publishRequest.state = "idle"
state.publishRequest.error = null
}
}

function observedNewPush(state: PublicationState) {
const request = state.publishRequest
return (
state.stats.remoteBranch?.state === "present" &&
state.stats.remoteBranch.remoteSha === state.stats.commitSha &&
(request.remoteState === "absent" ||
(request.remoteState === "present" && request.remoteSha !== state.stats.commitSha) ||
Boolean(
state.stats.workflow.run?.databaseId &&
state.stats.workflow.run.databaseId !== request.workflowRunId,
))
)
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { capturePublishTarget, samePublishTarget } from "../publish-target/index.mjs"
import { readWorkingTreeChanges } from "../working-tree-changes/index.mjs"

export async function preparePublish(context, preferences) {
const target = await capturePublishTarget(context)
const changes = await readWorkingTreeChanges(context.repositoryPath)
Expand Down
25 changes: 25 additions & 0 deletions .github/extensions/replica-prototype/prepare-publish/index.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { capturePublishTarget, samePublishTarget } from "../publish-target/index.mjs"
import type { CanvasContext } from "../create-canvas-context/index.mjs"
import type { PublishPreferences } from "../publish-preferences/index.mjs"
import { readWorkingTreeChanges } from "../working-tree-changes/index.mjs"

export async function preparePublish(
context: CanvasContext,
preferences: Pick<PublishPreferences, "read">,
) {
const target = await capturePublishTarget(context)
const changes = await readWorkingTreeChanges(context.repositoryPath)
const remember = await preferences.read(target)
if (!samePublishTarget(target, await capturePublishTarget(context)))
throw Object.assign(new Error("The checkout changed while preparing to publish. Try again."), {
statusCode: 409,
})
return {
target,
workingTree: changes.state,
...(changes.state === "unavailable"
? { workingTreeError: changes.error }
: { changes: { files: changes.files, localSkills: changes.localSkills } }),
remember,
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { createHash } from "node:crypto"
import { homedir } from "node:os"
import path from "node:path"

import { createPrivateStore } from "../private-store/index.mjs"

export function createPublishPreferences(
directory = path.join(
process.env.COPILOT_HOME || path.join(homedir(), ".copilot"),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { createHash } from "node:crypto"
import { homedir } from "node:os"
import path from "node:path"

import { createPrivateStore } from "../private-store/index.mjs"

type PreferenceTarget = { repository: unknown; branch: unknown }
export type PublishPreferences = ReturnType<typeof createPublishPreferences>

export function createPublishPreferences(
directory = path.join(
process.env.COPILOT_HOME || path.join(homedir(), ".copilot"),
"extensions",
"replica-prototype",
"artifacts",
"publish-preferences",
),
) {
const store = createPrivateStore(directory)
function name({ repository, branch }: PreferenceTarget) {
if (typeof repository !== "string" || !repository)
throw new Error("A verified repository is required for publishing preferences.")
if (typeof branch !== "string" || !branch)
throw new Error("A verified branch is required for publishing preferences.")
return `${createHash("sha256")
.update(JSON.stringify([repository.toLowerCase(), branch]))
.digest("hex")}.json`
}
return {
async read(target: PreferenceTarget) {
const value = await store.read(name(target))
if (value === null) return false
if (typeof value !== "boolean") throw new Error("Invalid saved publishing preference.")
return value
},
async write(target: PreferenceTarget, remember: boolean) {
if (typeof remember !== "boolean") throw new Error("Invalid publishing preference.")
await store.write(name(target), remember)
},
}
}
13 changes: 4 additions & 9 deletions .github/extensions/replica-prototype/publish-target/index.mjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import { createHash } from "node:crypto"

import { normalizeBranch, resolveRepository } from "../create-canvas-context/index.mjs"
import { gitShaPattern } from "../identity-patterns/index.mjs"
import { runCommand } from "../run-command/index.mjs"

export async function capturePublishTarget(context) {
const [head, branch, repository, origin, pushOrigin] = await Promise.all([
runCommand("git", ["rev-parse", "HEAD"], context.repositoryPath),
Expand All @@ -25,7 +22,7 @@ export async function capturePublishTarget(context) {
) {
throw new Error("The canvas branch no longer matches the current checkout. Refresh it first.")
}
const commitSha = head.ok && gitShaPattern.test(head.stdout.trim()) ? head.stdout.trim() : null
const commitSha = head.ok && /^[0-9a-f]{40}$/.test(head.stdout.trim()) ? head.stdout.trim() : null
if (!commitSha) throw new Error("The current checkout commit could not be verified.")
return {
repository,
Expand All @@ -37,7 +34,6 @@ export async function capturePublishTarget(context) {
: null,
}
}

export function sameCheckout(left, right) {
return (
left?.repository === right.repository &&
Expand All @@ -46,15 +42,14 @@ export function sameCheckout(left, right) {
left?.pushFingerprint === right.pushFingerprint
)
}

export function samePublishTarget(left, right) {
return sameCheckout(left, right) && left.commitSha === right.commitSha
return sameCheckout(left, right) && left != null && left.commitSha === right.commitSha
}

export async function continuesPublishTarget(context, original, current) {
if (!original) return false
if (!sameCheckout(original, current)) return false
if (samePublishTarget(original, current)) return true
if (!gitShaPattern.test(original.commitSha)) return false
if (!/^[0-9a-f]{40}$/.test(original.commitSha)) return false
const ancestor = await runCommand(
"git",
["merge-base", "--is-ancestor", original.commitSha, current.commitSha],
Expand Down
Loading
Loading