@saadnvd1/agent-os 0.3.786 → 0.3.792
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/components/Chat/composer/shrinkImage.ts +95 -0
- package/components/Chat/composer/shrinkPlan.test.ts +33 -0
- package/components/Chat/composer/shrinkPlan.ts +39 -0
- package/components/Chat/composer/useAttachFiles.ts +4 -3
- package/components/SidebarFooter.tsx +5 -3
- package/components/Updates/UpdateBanner.tsx +2 -2
- package/components/WhatsNew/WhatsNewButton.tsx +15 -18
- package/content/whats-new.json +19 -2
- package/lib/whats-new/lint.ts +7 -0
- package/lib/whats-new/unread.ts +0 -4
- package/package.json +1 -1
- package/scripts/autodeploy +5 -10
- package/scripts/autodeploy.test.ts +39 -2
- package/scripts/publish-detached +20 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { needsShrink, renamed, SHRINK_STEPS, targetSize } from "./shrinkPlan";
|
|
2
|
+
|
|
3
|
+
type Canvas = OffscreenCanvas | HTMLCanvasElement;
|
|
4
|
+
|
|
5
|
+
function makeCanvas(width: number, height: number): Canvas {
|
|
6
|
+
if (typeof OffscreenCanvas !== "undefined")
|
|
7
|
+
return new OffscreenCanvas(width, height);
|
|
8
|
+
const c = document.createElement("canvas");
|
|
9
|
+
c.width = width;
|
|
10
|
+
c.height = height;
|
|
11
|
+
return c;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function encode(
|
|
15
|
+
canvas: Canvas,
|
|
16
|
+
type: string,
|
|
17
|
+
quality?: number
|
|
18
|
+
): Promise<Blob | null> {
|
|
19
|
+
if ("convertToBlob" in canvas) return canvas.convertToBlob({ type, quality });
|
|
20
|
+
return new Promise((resolve) => canvas.toBlob(resolve, type, quality));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function hasAlpha(
|
|
24
|
+
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
|
|
25
|
+
w: number,
|
|
26
|
+
h: number
|
|
27
|
+
) {
|
|
28
|
+
const data = ctx.getImageData(0, 0, w, h).data;
|
|
29
|
+
for (let i = 3; i < data.length; i += 4) if (data[i] < 255) return true;
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type ShrinkResult = { ok: true; file: File } | { ok: false };
|
|
34
|
+
|
|
35
|
+
// Downscale an image that is over the byte cap or 2000px on its long edge.
|
|
36
|
+
// Returns the file untouched when it is already fine, and { ok: false } when
|
|
37
|
+
// it can't be decoded or still doesn't fit after every step.
|
|
38
|
+
export async function shrinkImage(
|
|
39
|
+
file: File,
|
|
40
|
+
cap: number
|
|
41
|
+
): Promise<ShrinkResult> {
|
|
42
|
+
let bitmap: ImageBitmap;
|
|
43
|
+
try {
|
|
44
|
+
bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
|
|
45
|
+
} catch {
|
|
46
|
+
// A file we can't decode is only a problem if it was too big anyway.
|
|
47
|
+
return file.size > cap ? { ok: false } : { ok: true, file };
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
if (!needsShrink(bitmap.width, bitmap.height, file.size, cap))
|
|
51
|
+
return { ok: true, file };
|
|
52
|
+
// Animated GIFs would lose their frames; only touch one that can't go as is.
|
|
53
|
+
if (file.type === "image/gif" && file.size <= cap)
|
|
54
|
+
return { ok: true, file };
|
|
55
|
+
|
|
56
|
+
let keepPng: boolean | null = null;
|
|
57
|
+
for (const { maxEdge, quality } of SHRINK_STEPS) {
|
|
58
|
+
const { width, height } = targetSize(
|
|
59
|
+
bitmap.width,
|
|
60
|
+
bitmap.height,
|
|
61
|
+
maxEdge
|
|
62
|
+
);
|
|
63
|
+
const canvas = makeCanvas(width, height);
|
|
64
|
+
const ctx = canvas.getContext("2d") as CanvasRenderingContext2D | null;
|
|
65
|
+
if (!ctx) return { ok: false };
|
|
66
|
+
ctx.drawImage(bitmap, 0, 0, width, height);
|
|
67
|
+
if (keepPng === null)
|
|
68
|
+
keepPng = file.type === "image/png" && hasAlpha(ctx, width, height);
|
|
69
|
+
if (keepPng) {
|
|
70
|
+
const png = await encode(canvas, "image/png");
|
|
71
|
+
if (png && png.size <= cap)
|
|
72
|
+
return {
|
|
73
|
+
ok: true,
|
|
74
|
+
file: new File([png], file.name, { type: "image/png" }),
|
|
75
|
+
};
|
|
76
|
+
keepPng = false;
|
|
77
|
+
}
|
|
78
|
+
// JPEG has no alpha: put the picture on white first.
|
|
79
|
+
ctx.globalCompositeOperation = "destination-over";
|
|
80
|
+
ctx.fillStyle = "#fff";
|
|
81
|
+
ctx.fillRect(0, 0, width, height);
|
|
82
|
+
const jpeg = await encode(canvas, "image/jpeg", quality);
|
|
83
|
+
if (jpeg && jpeg.size <= cap)
|
|
84
|
+
return {
|
|
85
|
+
ok: true,
|
|
86
|
+
file: new File([jpeg], renamed(file.name, "jpg"), {
|
|
87
|
+
type: "image/jpeg",
|
|
88
|
+
}),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return { ok: false };
|
|
92
|
+
} finally {
|
|
93
|
+
bitmap.close();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { needsShrink, renamed, SHRINK_STEPS, targetSize } from "./shrinkPlan";
|
|
3
|
+
|
|
4
|
+
describe("shrink plan", () => {
|
|
5
|
+
it("shrinks a big photo and leaves a small one alone", () => {
|
|
6
|
+
expect(needsShrink(5712, 4284, 8_000_000, 3_750_000)).toBe(true);
|
|
7
|
+
expect(needsShrink(1200, 800, 4_000_000, 3_750_000)).toBe(true);
|
|
8
|
+
expect(needsShrink(3000, 1000, 100_000, 3_750_000)).toBe(true);
|
|
9
|
+
expect(needsShrink(2000, 1500, 500_000, 3_750_000)).toBe(false);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("fits the long edge and keeps the aspect ratio", () => {
|
|
13
|
+
expect(targetSize(5712, 4284, 2000)).toEqual({ width: 2000, height: 1500 });
|
|
14
|
+
expect(targetSize(4284, 5712, 1600)).toEqual({ width: 1200, height: 1600 });
|
|
15
|
+
expect(targetSize(800, 600, 2000)).toEqual({ width: 800, height: 600 });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("steps quality down before size", () => {
|
|
19
|
+
expect(SHRINK_STEPS.map((s) => `${s.maxEdge}@${s.quality}`)).toEqual([
|
|
20
|
+
"2000@0.85",
|
|
21
|
+
"2000@0.75",
|
|
22
|
+
"2000@0.65",
|
|
23
|
+
"1600@0.75",
|
|
24
|
+
"1600@0.65",
|
|
25
|
+
]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("swaps the extension", () => {
|
|
29
|
+
expect(renamed("IMG_1.HEIC", "jpg")).toBe("IMG_1.jpg");
|
|
30
|
+
expect(renamed("a.b.png", "jpg")).toBe("a.b.jpg");
|
|
31
|
+
expect(renamed("noext", "jpg")).toBe("noext.jpg");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The decisions behind shrinking a big photo before it is attached, kept pure
|
|
2
|
+
// so they can be tested without a canvas.
|
|
3
|
+
|
|
4
|
+
export const MAX_EDGE = 2000;
|
|
5
|
+
const FALLBACK_EDGE = 1600;
|
|
6
|
+
|
|
7
|
+
export interface ShrinkStep {
|
|
8
|
+
maxEdge: number;
|
|
9
|
+
quality: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Tried in order until the result fits the cap.
|
|
13
|
+
export const SHRINK_STEPS: ShrinkStep[] = [
|
|
14
|
+
{ maxEdge: MAX_EDGE, quality: 0.85 },
|
|
15
|
+
{ maxEdge: MAX_EDGE, quality: 0.75 },
|
|
16
|
+
{ maxEdge: MAX_EDGE, quality: 0.65 },
|
|
17
|
+
{ maxEdge: FALLBACK_EDGE, quality: 0.75 },
|
|
18
|
+
{ maxEdge: FALLBACK_EDGE, quality: 0.65 },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export const needsShrink = (
|
|
22
|
+
width: number,
|
|
23
|
+
height: number,
|
|
24
|
+
bytes: number,
|
|
25
|
+
cap: number
|
|
26
|
+
) => bytes > cap || Math.max(width, height) > MAX_EDGE;
|
|
27
|
+
|
|
28
|
+
// Fit inside maxEdge on the long side; never enlarge.
|
|
29
|
+
export function targetSize(width: number, height: number, maxEdge: number) {
|
|
30
|
+
const scale = Math.min(1, maxEdge / Math.max(width, height));
|
|
31
|
+
return {
|
|
32
|
+
width: Math.max(1, Math.round(width * scale)),
|
|
33
|
+
height: Math.max(1, Math.round(height * scale)),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// photo.HEIC -> photo.jpg; a name without an extension just gains one.
|
|
38
|
+
export const renamed = (name: string, ext: "jpg" | "png") =>
|
|
39
|
+
`${name.replace(/\.[^./\\]+$/, "")}.${ext}`;
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import type { ChatFile, ChatImage } from "@/lib/chat/events";
|
|
17
17
|
import { uploadFile } from "@/lib/file-upload";
|
|
18
18
|
import { isImage } from "./paste";
|
|
19
|
+
import { shrinkImage } from "./shrinkImage";
|
|
19
20
|
|
|
20
21
|
export interface Upload {
|
|
21
22
|
key: string;
|
|
@@ -79,9 +80,9 @@ export function useAttachFiles({
|
|
|
79
80
|
if (held.current + images.length + others.length >= limits.max)
|
|
80
81
|
error = `You can attach up to ${limits.max} files to one message.`;
|
|
81
82
|
else if (isImage(f)) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
else
|
|
83
|
+
const shrunk = await shrinkImage(f, limits.maxImageBytes);
|
|
84
|
+
if (shrunk.ok) images.push(shrunk.file);
|
|
85
|
+
else error = tooBigMessage(f.name, f.size, limits.maxImageBytes);
|
|
85
86
|
} else if (!typeFor(f.name, limits.types))
|
|
86
87
|
error = unsupportedMessage(f.name);
|
|
87
88
|
else if (f.size > limits.maxBytes)
|
|
@@ -9,10 +9,12 @@ import {
|
|
|
9
9
|
export function SidebarFooter() {
|
|
10
10
|
return (
|
|
11
11
|
<div className="mt-auto px-3 pt-2 pb-3">
|
|
12
|
-
<
|
|
13
|
-
<div className="mt-1 flex items-center justify-between">
|
|
12
|
+
<div className="flex items-center justify-between">
|
|
14
13
|
<span className="label-mono text-muted-foreground">Theme</span>
|
|
15
|
-
<
|
|
14
|
+
<div className="flex items-center">
|
|
15
|
+
<WhatsNewButton />
|
|
16
|
+
<ThemeToggle />
|
|
17
|
+
</div>
|
|
16
18
|
</div>
|
|
17
19
|
<div className="text-muted-foreground/50 mt-2 text-center text-[10px]">
|
|
18
20
|
<Tooltip>
|
|
@@ -152,7 +152,7 @@ export function UpdateBannerView({
|
|
|
152
152
|
{state === "available" && (
|
|
153
153
|
<button
|
|
154
154
|
type="button"
|
|
155
|
-
className="text-
|
|
155
|
+
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 items-center text-xs underline md:min-h-0"
|
|
156
156
|
onClick={() => whatsNewActions.open(true)}
|
|
157
157
|
>
|
|
158
158
|
See what's new
|
|
@@ -163,7 +163,7 @@ export function UpdateBannerView({
|
|
|
163
163
|
href={latest.url}
|
|
164
164
|
target="_blank"
|
|
165
165
|
rel="noreferrer noopener"
|
|
166
|
-
className="text-
|
|
166
|
+
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 items-center text-xs underline md:min-h-0"
|
|
167
167
|
>
|
|
168
168
|
Release notes
|
|
169
169
|
</a>
|
|
@@ -1,36 +1,33 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { Sparkles } from "lucide-react";
|
|
4
|
-
import {
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
5
|
import { useWhatsNewHead } from "@/data/whats-new";
|
|
6
6
|
import { useWhatsNewSeen } from "@/hooks/useWhatsNewSeen";
|
|
7
|
-
import { unreadLabel } from "@/lib/whats-new/unread";
|
|
8
7
|
import { whatsNewActions } from "@/stores/whatsNew";
|
|
9
8
|
|
|
10
|
-
/**
|
|
9
|
+
/** A small footer icon that opens the one dialog (WhatsNewHost); a dot marks unread. */
|
|
11
10
|
export function WhatsNewButton() {
|
|
12
11
|
const { data: head } = useWhatsNewHead();
|
|
13
12
|
const { unread } = useWhatsNewSeen(head);
|
|
14
13
|
return (
|
|
15
|
-
<
|
|
14
|
+
<Button
|
|
16
15
|
type="button"
|
|
16
|
+
variant="ghost"
|
|
17
|
+
size="icon"
|
|
18
|
+
title="What's New"
|
|
19
|
+
aria-label={unread > 0 ? "What's New (unread changes)" : "What's New"}
|
|
17
20
|
onClick={() => whatsNewActions.open()}
|
|
18
|
-
|
|
21
|
+
// The ::after widens the tap area to 44px on a phone without adding height.
|
|
22
|
+
className="text-muted-foreground relative h-8 w-8 after:absolute after:-inset-1.5 after:content-[''] md:after:hidden [@media(hover:none)]:md:h-11 [@media(hover:none)]:md:w-11"
|
|
19
23
|
>
|
|
20
|
-
<Sparkles className="
|
|
21
|
-
<span className="flex-1 text-left">What's New</span>
|
|
24
|
+
<Sparkles className="h-4 w-4" />
|
|
22
25
|
{unread > 0 && (
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
<span
|
|
28
|
-
role="img"
|
|
29
|
-
aria-label="Unread changes"
|
|
30
|
-
className="bg-primary size-2 shrink-0 rounded-full"
|
|
31
|
-
/>
|
|
32
|
-
</>
|
|
26
|
+
<span
|
|
27
|
+
data-testid="whats-new-dot"
|
|
28
|
+
className="bg-primary absolute top-1.5 right-1.5 size-1.5 rounded-full"
|
|
29
|
+
/>
|
|
33
30
|
)}
|
|
34
|
-
</
|
|
31
|
+
</Button>
|
|
35
32
|
);
|
|
36
33
|
}
|
package/content/whats-new.json
CHANGED
|
@@ -1694,8 +1694,9 @@
|
|
|
1694
1694
|
"date": "2026-10-10",
|
|
1695
1695
|
"kind": "new",
|
|
1696
1696
|
"area": "AgentOS",
|
|
1697
|
-
"text": "
|
|
1698
|
-
"url": "https://github.com/saadnvd1/agent-os/pull/184"
|
|
1697
|
+
"text": "Internal tooling update.",
|
|
1698
|
+
"url": "https://github.com/saadnvd1/agent-os/pull/184",
|
|
1699
|
+
"hidden": true
|
|
1699
1700
|
},
|
|
1700
1701
|
{
|
|
1701
1702
|
"id": 200,
|
|
@@ -1768,6 +1769,22 @@
|
|
|
1768
1769
|
"area": "AgentOS",
|
|
1769
1770
|
"text": "A What's New list in the sidebar shows what changed in each update.",
|
|
1770
1771
|
"url": "https://github.com/saadnvd1/agent-os/pull/525"
|
|
1772
|
+
},
|
|
1773
|
+
{
|
|
1774
|
+
"id": 528,
|
|
1775
|
+
"date": "2026-10-30",
|
|
1776
|
+
"kind": "improved",
|
|
1777
|
+
"area": "AgentOS",
|
|
1778
|
+
"text": "What's New now sits as a small icon in the sidebar footer.",
|
|
1779
|
+
"url": "https://github.com/saadnvd1/agent-os/pull/528"
|
|
1780
|
+
},
|
|
1781
|
+
{
|
|
1782
|
+
"id": 529,
|
|
1783
|
+
"date": "2026-10-31",
|
|
1784
|
+
"kind": "fixed",
|
|
1785
|
+
"area": "Chat",
|
|
1786
|
+
"text": "Large photos from your phone now attach to a chat instead of being refused.",
|
|
1787
|
+
"url": "https://github.com/saadnvd1/agent-os/pull/529"
|
|
1771
1788
|
}
|
|
1772
1789
|
]
|
|
1773
1790
|
}
|
package/lib/whats-new/lint.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { agentosHome } from "@/lib/agentos-home";
|
|
4
|
+
import { loadTerms } from "@/scripts/hooks/banned-terms";
|
|
4
5
|
import type { Entry } from "./schema";
|
|
5
6
|
|
|
6
7
|
// The repo is public, so this checks only generic shapes. Names that must
|
|
@@ -25,6 +26,10 @@ const GENERIC: [string, RegExp][] = [
|
|
|
25
26
|
["a vulnerability", /vulnerab|exploit|\bRCE\b/i],
|
|
26
27
|
];
|
|
27
28
|
|
|
29
|
+
// The repo-wide banned terms (scripts/check fails on them), read once.
|
|
30
|
+
let banned: RegExp[] | null = null;
|
|
31
|
+
const bannedTerms = () => (banned ??= loadTerms());
|
|
32
|
+
|
|
28
33
|
const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
29
34
|
|
|
30
35
|
/** Why this text can't ship, or null when it can. */
|
|
@@ -34,6 +39,8 @@ export function lintText(
|
|
|
34
39
|
): string | null {
|
|
35
40
|
for (const [what, re] of GENERIC)
|
|
36
41
|
if (re.test(text)) return `contains ${what}`;
|
|
42
|
+
if (bannedTerms().some((re) => re.test(text)))
|
|
43
|
+
return "contains a banned term";
|
|
37
44
|
for (const name of blocklist) {
|
|
38
45
|
if (
|
|
39
46
|
new RegExp(
|
package/lib/whats-new/unread.ts
CHANGED
|
@@ -2,13 +2,9 @@
|
|
|
2
2
|
// Pure, so a native client can use the same rules.
|
|
3
3
|
|
|
4
4
|
export const SEEN_KEY = "aos:whatsnew:seen";
|
|
5
|
-
export const UNREAD_CAP = 9;
|
|
6
5
|
|
|
7
6
|
/** How many of the newest entries are newer than the seen id (capped by the head's list). */
|
|
8
7
|
export function unreadCount(recent: number[], seen: number | null): number {
|
|
9
8
|
if (seen === null) return 0;
|
|
10
9
|
return recent.filter((id) => id > seen).length;
|
|
11
10
|
}
|
|
12
|
-
|
|
13
|
-
export const unreadLabel = (n: number) =>
|
|
14
|
-
n > UNREAD_CAP ? `${UNREAD_CAP}+` : String(n);
|
package/package.json
CHANGED
package/scripts/autodeploy
CHANGED
|
@@ -135,15 +135,10 @@ fi
|
|
|
135
135
|
# Publishing waits minutes for npm to serve a version, so it runs on its own
|
|
136
136
|
# with its own lock and never holds up the next deploy.
|
|
137
137
|
if [ "$head" != "$published" ] && mkdir "$state/publish.lock" 2>/dev/null; then
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
version="$base.$(git rev-list --count "$head")"
|
|
144
|
-
(cd "$tmp" && npm version "$version" --no-git-tag-version --allow-same-version >/dev/null)
|
|
145
|
-
log "Publishing $version"
|
|
146
|
-
"$tmp/scripts/publish" && echo "$head" > "$state/published"
|
|
147
|
-
) &
|
|
138
|
+
# Its own session and command line: serviceman kills the last run's process
|
|
139
|
+
# group and orphans named like this job, which took a backgrounded subshell
|
|
140
|
+
# with it every minute.
|
|
141
|
+
nohup perl -e 'use POSIX; POSIX::setsid(); exec @ARGV' \
|
|
142
|
+
"$PWD/scripts/publish-detached" "$head" "$state" </dev/null &
|
|
148
143
|
disown
|
|
149
144
|
fi
|
|
@@ -28,6 +28,18 @@ function setup() {
|
|
|
28
28
|
fs.mkdirSync(path.join(work, "bin"));
|
|
29
29
|
fs.writeFileSync(path.join(work, "bin/aos"), "");
|
|
30
30
|
fs.writeFileSync(path.join(work, "scripts/wait-quiet"), "#!/bin/sh\n");
|
|
31
|
+
fs.copyFileSync(
|
|
32
|
+
path.join(__dirname, "publish-detached"),
|
|
33
|
+
path.join(work, "scripts/publish-detached")
|
|
34
|
+
);
|
|
35
|
+
fs.writeFileSync(path.join(work, "package.json"), '{"version":"1.2.0"}\n');
|
|
36
|
+
// Stands in for the npm publish: records its process group, then takes a while.
|
|
37
|
+
fs.writeFileSync(
|
|
38
|
+
path.join(work, "scripts/publish"),
|
|
39
|
+
'#!/bin/sh\necho "$(ps -o pgid= -p $$ | tr -d " ")" >> "$HOME/publish-starts"\nsleep 2\n'
|
|
40
|
+
);
|
|
41
|
+
fs.chmodSync(path.join(work, "scripts/publish-detached"), 0o755);
|
|
42
|
+
fs.chmodSync(path.join(work, "scripts/publish"), 0o755);
|
|
31
43
|
fs.writeFileSync(
|
|
32
44
|
path.join(work, "scripts/redeploy"),
|
|
33
45
|
"#!/bin/sh\ngit pull -q origin main && echo x >> ../restarts\n"
|
|
@@ -45,7 +57,7 @@ function setup() {
|
|
|
45
57
|
commit("first");
|
|
46
58
|
sh(root, "clone", "-q", origin, clone);
|
|
47
59
|
const state = path.join(home, ".agent-os/autodeploy");
|
|
48
|
-
const run = () =>
|
|
60
|
+
const run = (extra: Record<string, string> = {}) =>
|
|
49
61
|
execFileSync(path.join(clone, "scripts/autodeploy"), [], {
|
|
50
62
|
cwd: clone,
|
|
51
63
|
encoding: "utf-8",
|
|
@@ -57,6 +69,7 @@ function setup() {
|
|
|
57
69
|
AGENTOS_RESTART: "true",
|
|
58
70
|
AOS_BIN_DIR: path.join(root, "linkbin"),
|
|
59
71
|
AGENT_OS_HOME: path.join(home, ".agent-os"),
|
|
72
|
+
...extra,
|
|
60
73
|
},
|
|
61
74
|
});
|
|
62
75
|
const restarts = () => {
|
|
@@ -71,7 +84,7 @@ function setup() {
|
|
|
71
84
|
};
|
|
72
85
|
const deployed = () =>
|
|
73
86
|
fs.readFileSync(path.join(state, "deployed"), "utf-8").trim();
|
|
74
|
-
return { root, clone, commit, run, restarts, deployed, state };
|
|
87
|
+
return { root, home, clone, commit, run, restarts, deployed, state };
|
|
75
88
|
}
|
|
76
89
|
|
|
77
90
|
describe("autodeploy restart gap", () => {
|
|
@@ -187,3 +200,27 @@ describe("autodeploy CLI links", () => {
|
|
|
187
200
|
);
|
|
188
201
|
});
|
|
189
202
|
});
|
|
203
|
+
|
|
204
|
+
describe("autodeploy publish", () => {
|
|
205
|
+
it("keeps publishing in its own process group across the next run", async () => {
|
|
206
|
+
const t = setup();
|
|
207
|
+
const head = t.commit("a");
|
|
208
|
+
const env = { AGENTOS_PUBLISH: "1", AGENTOS_WHATS_NEW: "0" };
|
|
209
|
+
t.run(env);
|
|
210
|
+
t.run(env);
|
|
211
|
+
const starts = path.join(t.home, "publish-starts");
|
|
212
|
+
const lock = path.join(t.state, "publish.lock");
|
|
213
|
+
const published = path.join(t.state, "published");
|
|
214
|
+
for (let i = 0; i < 100 && !fs.existsSync(published); i++)
|
|
215
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
216
|
+
const groups = fs.readFileSync(starts, "utf-8").trim().split("\n");
|
|
217
|
+
expect(groups).toHaveLength(1);
|
|
218
|
+
expect(Number(groups[0])).not.toBe(
|
|
219
|
+
Number(execFileSync("ps", ["-o", "pgid=", "-p", String(process.pid)]))
|
|
220
|
+
);
|
|
221
|
+
expect(fs.readFileSync(published, "utf-8").trim()).toBe(head);
|
|
222
|
+
for (let i = 0; i < 20 && fs.existsSync(lock); i++)
|
|
223
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
224
|
+
expect(fs.existsSync(lock)).toBe(false);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# The publish half of scripts/autodeploy, run in its own process group under
|
|
3
|
+
# its own command line: serviceman kills the previous cron run's process group
|
|
4
|
+
# and any orphan still named like the job, so a forked copy of autodeploy
|
|
5
|
+
# would die with the next minute's run. Holds $state/publish.lock (taken by the
|
|
6
|
+
# caller) and releases it when done.
|
|
7
|
+
# scripts/publish-detached <sha> <state dir>
|
|
8
|
+
set -euo pipefail
|
|
9
|
+
head=$1
|
|
10
|
+
state=$2
|
|
11
|
+
cd "$(dirname "$0")/.."
|
|
12
|
+
log() { echo "$(date '+%Y-%m-%d %-I:%M %p') $*"; }
|
|
13
|
+
tmp=$(mktemp -d)
|
|
14
|
+
trap 'rmdir "$state/publish.lock"; rm -rf "$tmp"' EXIT
|
|
15
|
+
git archive "$head" | tar -x -C "$tmp"
|
|
16
|
+
base=$(node -p 'require("./package.json").version.split(".").slice(0,2).join(".")')
|
|
17
|
+
version="$base.$(git rev-list --count "$head")"
|
|
18
|
+
(cd "$tmp" && npm version "$version" --no-git-tag-version --allow-same-version >/dev/null)
|
|
19
|
+
log "Publishing $version"
|
|
20
|
+
"$tmp/scripts/publish" && echo "$head" > "$state/published"
|