@saadnvd1/agent-os 0.3.459 → 0.3.461
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 +53 -1
- package/app/api/away/route.ts +56 -0
- package/app/api/away/seen/route.ts +12 -0
- package/app/api/health/route.ts +9 -0
- package/app/api/workspaces/[id]/route.ts +8 -1
- package/app/page.tsx +2 -0
- package/components/Away/AwayReport.tsx +16 -0
- package/components/Away/AwayReportDialog.tsx +193 -0
- package/components/Away/AwayToggle.tsx +91 -0
- package/components/Away/time.test.ts +20 -0
- package/components/Away/time.ts +32 -0
- package/components/Away/useAwayCommands.ts +43 -0
- package/components/Chat/Messages.tsx +1 -0
- package/components/CommandPalette/useAppCommands.ts +2 -0
- package/components/Pane/MobileTabBar.tsx +14 -1
- package/components/SessionList/SidebarHeader.tsx +2 -0
- package/components/Workspaces/WorkspaceSettings.tsx +9 -1
- package/data/away/index.ts +73 -0
- package/data/push/topics.ts +4 -0
- package/data/workspaces/index.ts +6 -1
- package/lib/away/brief.ts +23 -0
- package/lib/away/deploy.ts +173 -0
- package/lib/away/report.ts +398 -0
- package/lib/away/run.ts +445 -0
- package/lib/away/state.ts +201 -0
- package/lib/away/sweep.ts +216 -0
- package/lib/away/view.ts +51 -0
- package/lib/chat/events.ts +1 -1
- package/lib/db/changes.ts +3 -0
- package/lib/db/migrations.ts +101 -0
- package/lib/db/types.ts +9 -0
- package/lib/notify/index.ts +7 -0
- package/lib/orchestrator/brief.ts +2 -0
- package/lib/orchestrator/conditions.ts +10 -3
- package/lib/orchestrator/digest.ts +11 -8
- package/lib/orchestrator/notes.ts +8 -1
- package/lib/orchestrator/pr-wake.ts +385 -0
- package/lib/orchestrator/watcher.ts +12 -2
- package/lib/project-config/agentos.schema.json +24 -0
- package/lib/project-config/schema.ts +17 -0
- package/lib/tasks/gh.ts +2 -0
- package/lib/tasks/pr-activity.ts +295 -0
- package/lib/tasks/state.ts +9 -1
- package/lib/workspaces.ts +8 -2
- package/package.json +1 -1
|
@@ -35,6 +35,7 @@ import { settingsUiActions } from "@/stores/settingsUi";
|
|
|
35
35
|
import { usageUiActions } from "@/stores/usageUi";
|
|
36
36
|
import { WorkspaceSwitcher } from "./WorkspaceSwitcher";
|
|
37
37
|
import { LoadGauge } from "./LoadGauge";
|
|
38
|
+
import { AwayToggle } from "@/components/Away/AwayToggle";
|
|
38
39
|
import { useSchedulesGlance } from "./useSchedulesGlance";
|
|
39
40
|
import { cn } from "@/lib/utils";
|
|
40
41
|
|
|
@@ -67,6 +68,7 @@ export function SidebarHeader(props: SidebarHeaderProps) {
|
|
|
67
68
|
<LoadGauge />
|
|
68
69
|
</div>
|
|
69
70
|
<div className="flex shrink-0 items-center gap-0.5">
|
|
71
|
+
<AwayToggle />
|
|
70
72
|
<ADropdownMenu
|
|
71
73
|
trigger={
|
|
72
74
|
<Button
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { SettingToggle } from "@/components/Settings/SettingToggle";
|
|
4
|
+
import { useUpdateWorkspace, useWorkspacesQuery } from "@/data/workspaces";
|
|
4
5
|
import { TaskLimitForm } from "./TaskLimitForm";
|
|
5
6
|
import { RulesForm } from "./RulesForm";
|
|
6
7
|
import { FreshStartForm } from "./FreshStartForm";
|
|
@@ -8,6 +9,7 @@ import { FreshStartForm } from "./FreshStartForm";
|
|
|
8
9
|
// Settings > Workspaces: each workspace's own settings.
|
|
9
10
|
export function WorkspaceSettings() {
|
|
10
11
|
const { data: workspaces, isPending, isError, error } = useWorkspacesQuery();
|
|
12
|
+
const update = useUpdateWorkspace();
|
|
11
13
|
|
|
12
14
|
if (isPending)
|
|
13
15
|
return <div className="bg-muted/40 h-32 animate-pulse rounded-lg" />;
|
|
@@ -39,6 +41,12 @@ export function WorkspaceSettings() {
|
|
|
39
41
|
workspace={w}
|
|
40
42
|
/>
|
|
41
43
|
<RulesForm workspaceId={w.id} />
|
|
44
|
+
<SettingToggle
|
|
45
|
+
title="Wake tasks on CI failures and reviews"
|
|
46
|
+
detail="A task's agent is messaged once when a check fails on its PR's head, with the log's tail, and when someone reviews or comments on it."
|
|
47
|
+
checked={w.wake_tasks_on_pr}
|
|
48
|
+
onChange={(on) => update.mutate({ id: w.id, wakeTasksOnPR: on })}
|
|
49
|
+
/>
|
|
42
50
|
</section>
|
|
43
51
|
))}
|
|
44
52
|
<FreshStartForm />
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import type { AwayView } from "@/lib/away/view";
|
|
3
|
+
import { post } from "../orchestrators";
|
|
4
|
+
import { usePollWhenOffline } from "../push/connection";
|
|
5
|
+
|
|
6
|
+
export const awayKeys = {
|
|
7
|
+
all: ["away"] as const,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function useAwayQuery() {
|
|
11
|
+
return useQuery({
|
|
12
|
+
queryKey: awayKeys.all,
|
|
13
|
+
queryFn: async () => {
|
|
14
|
+
const res = await fetch("/api/away");
|
|
15
|
+
if (!res.ok) throw new Error(`Request failed (${res.status})`);
|
|
16
|
+
return (await res.json()) as AwayView;
|
|
17
|
+
},
|
|
18
|
+
staleTime: 10_000,
|
|
19
|
+
// Pushed when it changes (away_periods); a minute catches it running
|
|
20
|
+
// out while the stream is down.
|
|
21
|
+
refetchInterval: usePollWhenOffline(60_000),
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// expiresAt null: until he's back. `false` turns it off.
|
|
26
|
+
export function useSetAway() {
|
|
27
|
+
const queryClient = useQueryClient();
|
|
28
|
+
return useMutation({
|
|
29
|
+
mutationFn: (on: false | { expiresAt: Date | null }) =>
|
|
30
|
+
post(
|
|
31
|
+
"/api/away",
|
|
32
|
+
on
|
|
33
|
+
? { on: true, expiresAt: on.expiresAt?.toISOString() ?? null }
|
|
34
|
+
: { on: false }
|
|
35
|
+
) as Promise<AwayView>,
|
|
36
|
+
onSuccess: (view) => queryClient.setQueryData(awayKeys.all, view),
|
|
37
|
+
onSettled: () => queryClient.invalidateQueries({ queryKey: awayKeys.all }),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function useDismissAwayReport() {
|
|
42
|
+
const queryClient = useQueryClient();
|
|
43
|
+
return useMutation({
|
|
44
|
+
mutationFn: (id: number) => post("/api/away/seen", { id }),
|
|
45
|
+
onMutate: () =>
|
|
46
|
+
queryClient.setQueryData<AwayView>(awayKeys.all, (prev) =>
|
|
47
|
+
prev ? { ...prev, report: null } : prev
|
|
48
|
+
),
|
|
49
|
+
onSettled: () => queryClient.invalidateQueries({ queryKey: awayKeys.all }),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The choices the toggle offers, as when each ends.
|
|
54
|
+
export const AWAY_DURATIONS = [
|
|
55
|
+
{ id: "1h", label: "For 1 hour" },
|
|
56
|
+
{ id: "4h", label: "For 4 hours" },
|
|
57
|
+
{ id: "morning", label: "Until morning" },
|
|
58
|
+
{ id: "back", label: "Until I'm back" },
|
|
59
|
+
] as const;
|
|
60
|
+
|
|
61
|
+
export type AwayDuration = (typeof AWAY_DURATIONS)[number]["id"];
|
|
62
|
+
|
|
63
|
+
// "Until morning" is 8 AM, local: today's if it's still before 5 AM,
|
|
64
|
+
// else tomorrow's.
|
|
65
|
+
export function awayEnds(d: AwayDuration, now = new Date()): Date | null {
|
|
66
|
+
if (d === "1h") return new Date(now.getTime() + 60 * 60 * 1000);
|
|
67
|
+
if (d === "4h") return new Date(now.getTime() + 4 * 60 * 60 * 1000);
|
|
68
|
+
if (d === "back") return null;
|
|
69
|
+
const morning = new Date(now);
|
|
70
|
+
morning.setHours(8, 0, 0, 0);
|
|
71
|
+
if (now.getHours() >= 5) morning.setDate(morning.getDate() + 1);
|
|
72
|
+
return morning;
|
|
73
|
+
}
|
package/data/push/topics.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { ruleKeys } from "../orchestrators/rules";
|
|
|
12
12
|
import { workspaceKeys } from "../workspaces";
|
|
13
13
|
import { scheduleKeys } from "../schedules";
|
|
14
14
|
import { archivedKeys } from "../done";
|
|
15
|
+
import { awayKeys } from "../away";
|
|
15
16
|
|
|
16
17
|
// What each pushed topic (a table that changed, lib/db/changes.ts, or a
|
|
17
18
|
// pushed view) means the browser should refetch. Only queries on screen
|
|
@@ -56,6 +57,8 @@ function keysFor(topic: string): QueryKey[] {
|
|
|
56
57
|
return [orchestratorKeys.all];
|
|
57
58
|
case "orchestrator_rules":
|
|
58
59
|
return [ruleKeys.all];
|
|
60
|
+
case "away_periods":
|
|
61
|
+
return [awayKeys.all];
|
|
59
62
|
case "dev_servers":
|
|
60
63
|
return [devServerKeys.all];
|
|
61
64
|
default:
|
|
@@ -96,6 +99,7 @@ export const ALL_TOPICS = [
|
|
|
96
99
|
"schedules",
|
|
97
100
|
"orchestrator_asks",
|
|
98
101
|
"orchestrator_rules",
|
|
102
|
+
"away_periods",
|
|
99
103
|
"dev_servers",
|
|
100
104
|
];
|
|
101
105
|
|
package/data/workspaces/index.ts
CHANGED
|
@@ -53,6 +53,8 @@ export function useUpdateWorkspace() {
|
|
|
53
53
|
collapsed?: boolean;
|
|
54
54
|
// Running tasks at once; null for no limit.
|
|
55
55
|
maxRunningTasks?: number | null;
|
|
56
|
+
// Message tasks about failing checks and reviews on their PRs.
|
|
57
|
+
wakeTasksOnPR?: boolean;
|
|
56
58
|
}) =>
|
|
57
59
|
json(
|
|
58
60
|
await fetch(`/api/workspaces/${id}`, {
|
|
@@ -61,7 +63,7 @@ export function useUpdateWorkspace() {
|
|
|
61
63
|
body: JSON.stringify(updates),
|
|
62
64
|
})
|
|
63
65
|
),
|
|
64
|
-
onMutate: async ({ id, maxRunningTasks, ...updates }) => {
|
|
66
|
+
onMutate: async ({ id, maxRunningTasks, wakeTasksOnPR, ...updates }) => {
|
|
65
67
|
await queryClient.cancelQueries({ queryKey: workspaceKeys.list() });
|
|
66
68
|
queryClient.setQueryData<Workspace[]>(workspaceKeys.list(), (prev) =>
|
|
67
69
|
prev?.map((w) =>
|
|
@@ -72,6 +74,9 @@ export function useUpdateWorkspace() {
|
|
|
72
74
|
...(maxRunningTasks !== undefined && {
|
|
73
75
|
max_running_tasks: maxRunningTasks,
|
|
74
76
|
}),
|
|
77
|
+
...(wakeTasksOnPR !== undefined && {
|
|
78
|
+
wake_tasks_on_pr: wakeTasksOnPR,
|
|
79
|
+
}),
|
|
75
80
|
}
|
|
76
81
|
: w
|
|
77
82
|
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an orchestrator is told while Saad is away: the event that turns
|
|
3
|
+
* Away on, and the line leading its brief's digest on a fresh start.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
activeAway,
|
|
8
|
+
clockTime,
|
|
9
|
+
fromSql,
|
|
10
|
+
untilText,
|
|
11
|
+
type AwayRow,
|
|
12
|
+
} from "./state";
|
|
13
|
+
import { SWEEP_EVERY_MS } from "./sweep";
|
|
14
|
+
|
|
15
|
+
export function awayOnLine(row: AwayRow, now = Date.now()): string {
|
|
16
|
+
return `Saad is away (Away mode, since ${clockTime(fromSql(row.started_at), now)}, ${untilText(row, now)}). Follow work through rather than only reacting to events: keep each task moving to merged and deployed, sign off what passes its gates, and pick up what stalled. Every ${SWEEP_EVERY_MS / 60000} minutes AgentOS sweeps: it nudges stalled tasks, asks idle ones with uncommitted work to commit and open a PR, runs lost reviews again and checks the deploy after each merge; what needs you comes as events, and each step is in your log. Interactive sessions (not tasks) are Saad's: in Away mode only ask them to wrap up, never drive them further. Hard lines still go to ask_saad; while he's away his asks reach his phone in one batch per sweep, so raise them as usual. \`aos notify\` is held for that batch too, so don't send one per item.`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// For the digest: the same line while Away is on, else nothing.
|
|
20
|
+
export function awayDigestLine(now = Date.now()): string | null {
|
|
21
|
+
const row = activeAway();
|
|
22
|
+
return row ? awayOnLine(row, now) : null;
|
|
23
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* After a merge, did the deploy land and the server come back healthy? Read
|
|
3
|
+
* from the project's `deploy` config (agentos.json): the file its deploy
|
|
4
|
+
* writes the deployed commit to (scripts/autodeploy's `deployed`), the file
|
|
5
|
+
* it writes a commit whose deploy failed to (`alerted`), and a health URL.
|
|
6
|
+
* A project without one isn't checked; a missing `deployed` file means no
|
|
7
|
+
* deploy runs on this machine, which isn't a failure either.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from "fs";
|
|
11
|
+
import type { ProjectConfig } from "../project-config";
|
|
12
|
+
import { run } from "../tasks/gh";
|
|
13
|
+
import { expandHome } from "../tasks/session";
|
|
14
|
+
import { selfUrl } from "../agents/self-env";
|
|
15
|
+
import { commitTime } from "../orchestrator/repo";
|
|
16
|
+
|
|
17
|
+
export type DeployConfig = NonNullable<ProjectConfig["deploy"]>;
|
|
18
|
+
|
|
19
|
+
export type DeployVerdict =
|
|
20
|
+
| { state: "ok"; detail: string }
|
|
21
|
+
| { state: "failed"; detail: string }
|
|
22
|
+
| { state: "pending" }
|
|
23
|
+
| { state: "unchecked"; detail: string };
|
|
24
|
+
|
|
25
|
+
export const DEPLOY_WITHIN_MIN = 30;
|
|
26
|
+
// After the deploy lands, the server gets this long to answer healthy.
|
|
27
|
+
export const HEALTH_GRACE_MS = 3 * 60 * 1000;
|
|
28
|
+
|
|
29
|
+
export interface DeployDeps {
|
|
30
|
+
// The commit a state file names, or null when there's no such file.
|
|
31
|
+
readSha: (file: string) => { sha: string; at: number } | null;
|
|
32
|
+
// Whether `sha` is in the history of `tip` (fetching it if need be);
|
|
33
|
+
// throws when that can't be told.
|
|
34
|
+
contains: (repo: string, tip: string, sha: string) => Promise<boolean>;
|
|
35
|
+
// startedAt, when the server says when it started (AgentOS's
|
|
36
|
+
// /api/health does), so a deploy that never restarted it is caught.
|
|
37
|
+
health: (
|
|
38
|
+
url: string
|
|
39
|
+
) => Promise<{ ok: boolean; why?: string; startedAt?: number }>;
|
|
40
|
+
// When GitHub made the merge commit, in ms (0 when unknown): the time a
|
|
41
|
+
// restart has to come after. Not when AgentOS noticed the merge, which
|
|
42
|
+
// can be after a restart that already took it.
|
|
43
|
+
committedAt: (repo: string, sha: string) => Promise<number>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const SHA = /^[0-9a-f]{40}$/;
|
|
47
|
+
|
|
48
|
+
function readSha(file: string): { sha: string; at: number } | null {
|
|
49
|
+
try {
|
|
50
|
+
const full = expandHome(file);
|
|
51
|
+
const sha = fs.readFileSync(full, "utf-8").slice(0, 80).trim();
|
|
52
|
+
return SHA.test(sha) ? { sha, at: fs.statSync(full).mtimeMs } : null;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function contains(
|
|
59
|
+
repo: string,
|
|
60
|
+
tip: string,
|
|
61
|
+
sha: string
|
|
62
|
+
): Promise<boolean> {
|
|
63
|
+
if (tip === sha) return true;
|
|
64
|
+
const ancestor = () =>
|
|
65
|
+
run("git", ["merge-base", "--is-ancestor", sha, tip], repo).then(
|
|
66
|
+
() => true,
|
|
67
|
+
() => false
|
|
68
|
+
);
|
|
69
|
+
if (await ancestor()) return true;
|
|
70
|
+
// The deployed commit may be newer than this checkout knows. A fetch that
|
|
71
|
+
// fails throws, so stale refs never read as "not deployed".
|
|
72
|
+
await run("git", ["fetch", "--quiet", "origin"], repo);
|
|
73
|
+
return ancestor();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function health(
|
|
77
|
+
url: string
|
|
78
|
+
): Promise<{ ok: boolean; why?: string; startedAt?: number }> {
|
|
79
|
+
try {
|
|
80
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
|
81
|
+
if (!res.ok) return { ok: false, why: `HTTP ${res.status}` };
|
|
82
|
+
const body = (await res.json().catch(() => null)) as {
|
|
83
|
+
startedAt?: unknown;
|
|
84
|
+
} | null;
|
|
85
|
+
const startedAt =
|
|
86
|
+
typeof body?.startedAt === "string" ? Date.parse(body.startedAt) : NaN;
|
|
87
|
+
return Number.isFinite(startedAt) ? { ok: true, startedAt } : { ok: true };
|
|
88
|
+
} catch (error) {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
why: error instanceof Error ? error.message : String(error),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const DEFAULT_DEPS: DeployDeps = {
|
|
97
|
+
readSha,
|
|
98
|
+
contains,
|
|
99
|
+
health,
|
|
100
|
+
committedAt: async (repo, sha) =>
|
|
101
|
+
(await commitTime(repo, sha).catch(() => 0)) * 1000,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// A path alone is this AgentOS server's own.
|
|
105
|
+
export const healthUrl = (health: string) =>
|
|
106
|
+
health.startsWith("/") ? `${selfUrl()}${health}` : health;
|
|
107
|
+
|
|
108
|
+
// Pending when it can't be told yet (git couldn't fetch): the next sweep
|
|
109
|
+
// asks again, rather than a blip reading as a failed deploy.
|
|
110
|
+
export async function checkDeploy(
|
|
111
|
+
input: Parameters<typeof judge>[0],
|
|
112
|
+
deps: DeployDeps = DEFAULT_DEPS
|
|
113
|
+
): Promise<DeployVerdict> {
|
|
114
|
+
try {
|
|
115
|
+
return await judge(input, deps);
|
|
116
|
+
} catch {
|
|
117
|
+
return { state: "pending" };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function judge(
|
|
122
|
+
input: {
|
|
123
|
+
repo: string;
|
|
124
|
+
config: DeployConfig | undefined;
|
|
125
|
+
mergeSha: string | null;
|
|
126
|
+
mergedAt: number;
|
|
127
|
+
now: number;
|
|
128
|
+
},
|
|
129
|
+
deps: DeployDeps = DEFAULT_DEPS
|
|
130
|
+
): Promise<DeployVerdict> {
|
|
131
|
+
const { config, mergeSha } = input;
|
|
132
|
+
if (!config)
|
|
133
|
+
return { state: "unchecked", detail: "no deploy check configured" };
|
|
134
|
+
if (!mergeSha) return { state: "pending" };
|
|
135
|
+
const short = mergeSha.slice(0, 7);
|
|
136
|
+
const deployed = deps.readSha(config.deployed);
|
|
137
|
+
if (!deployed)
|
|
138
|
+
return { state: "unchecked", detail: "no deploy runs on this machine" };
|
|
139
|
+
|
|
140
|
+
if (await deps.contains(input.repo, deployed.sha, mergeSha)) {
|
|
141
|
+
if (!config.health) return { state: "ok", detail: `${short} deployed` };
|
|
142
|
+
const url = healthUrl(config.health);
|
|
143
|
+
const answer = await deps.health(url);
|
|
144
|
+
const merged =
|
|
145
|
+
answer.ok && answer.startedAt !== undefined
|
|
146
|
+
? await deps.committedAt(input.repo, mergeSha)
|
|
147
|
+
: 0;
|
|
148
|
+
const stale = !!merged && answer.startedAt! < merged;
|
|
149
|
+
if (answer.ok && !stale)
|
|
150
|
+
return { state: "ok", detail: `${short} deployed, server healthy` };
|
|
151
|
+
if (input.now - deployed.at < HEALTH_GRACE_MS) return { state: "pending" };
|
|
152
|
+
return {
|
|
153
|
+
state: "failed",
|
|
154
|
+
detail: stale
|
|
155
|
+
? `${short} deployed, but the server answering ${config.health} hasn't restarted since the merge`
|
|
156
|
+
: `${short} deployed, but ${config.health} didn't answer healthy: ${answer.why ?? "no answer"}`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const failed = config.failed ? deps.readSha(config.failed) : null;
|
|
161
|
+
if (failed && (await deps.contains(input.repo, failed.sha, mergeSha)))
|
|
162
|
+
return {
|
|
163
|
+
state: "failed",
|
|
164
|
+
detail: `the deploy of ${failed.sha.slice(0, 7)} (which has ${short}) failed; see the deploy's log`,
|
|
165
|
+
};
|
|
166
|
+
const within = (config.within_minutes ?? DEPLOY_WITHIN_MIN) * 60 * 1000;
|
|
167
|
+
if (input.now - input.mergedAt >= within)
|
|
168
|
+
return {
|
|
169
|
+
state: "failed",
|
|
170
|
+
detail: `${short} not deployed ${Math.round(within / 60000)} minutes after the merge`,
|
|
171
|
+
};
|
|
172
|
+
return { state: "pending" };
|
|
173
|
+
}
|