דלגו לתוכן

גשרי צ'אט

גשר צ’אט הופך מוצר צ’אט חיצוני — נכון להיום Slack — לדרך מלאה אל תוך סביבת עבודה של Control Center. אזכרו את הבוט וסוכן מתעורר; השיבו בשרשור ב-Slack והשיחה ממשיכה; הקלידו פקודת קו נטוי ונפתח כרטיס. מתחת לפני השטח, אותם מרחבים, תמלילים, כרטיסים ושיוך שאפליקציית שולחן העבודה משתמשת בהם. הצ’אט הוא משטח מעל סביבת העבודה, לא עותק שלה.

Slack הוא הספק היחיד שנשלח עם המוצר. אין שום מימוש של Discord, Telegram, Teams או Matrix בשום מקום במוצר. היכולת נכתבה בלי תלות בספק בכוונה — הוספת מוצר שני היא ערך enum אחד, מתאם אחד ותוסף אחד — אבל כיום “צ’אט” פירושו Slack.

שני משטחים מכסים אותה, והם שייכים לאנשים שונים. חיבור סביבת עבודה ל-Slack והתאמת הבוט הם פעולות של מנהל תחת הגדרות → סביבת עבודה → כללי. קישור חשבון הצ’אט שלכם לזהות שלכם ב-Control Center הוא פעולה של חבר תחת הגדרות → סביבת עבודה → פרופיל וזהות.

שלוש החלטות מעצבות את כל היכולת: לאיזה כיוון רץ החיבור, מה מאשר הודעה שמגיעה מבחוץ והיכן הידע הספציפי למוצר רשאי לשכון.

הגשר לעולם אינו מאזין לספק. אל Slack מגיעים דרך Socket Mode: השרת מבקש מ-Slack כתובת socket ומתחבר החוצה, ואז מקבל אזכורים, הודעות ישירות ופקודות קו נטוי דרך החיבור הזה.

התוצאה היא העיקר. מחשב נייד מאחורי NAT, שרת ביתי בלי דומיין, מכונה שעוברת בין רשתות — כולם יכולים להריץ את הגשר בלי פורט נכנס, בלי מנהרה, בלי תעודת TLS ובלי כתובת ציבורית שצריך להחליף כשהיא משתנה. אין נקודת קצה של webhook לאבטח, כי אין נקודת קצה של webhook. כש-Control Center מרכיב את הגדרת האפליקציה הוא כופה Socket Mode ומסיר כל כתובת URL של בקשה, כי אפליקציה שמוגדרת לשניהם היא אפליקציה שעובדת רק חצי.

המחיר הוא חיבור שצריך להחזיק בחיים, וזו עבודה של השרת: ההתחברות מחדש היא השהיה מעריכית עם jitter שמוגבלת ל-30 שניות, סבב החלפת socket שיזם הספק מתחבר מחדש בלי להחמיר את ההשהיה הזו, ו_פרטי גישה שנדחו_ הם סוף הדרך — ניסיון חוזר אינסופי עם אסימון שבוטל רק יזכה בהגבלת קצב, ולכן הגשר עוצר וממתין לאסימונים חדשים, ומדווח את השגיאה של הספק עצמו מילה במילה בהגדרות.

סביבת עבודה בלי אפליקציית צ’אט לא עולה דבר: אין socket, אין טיימרים, אין תעבורה. וכל העניין מתחיל אחרי באנר המוכנות של השרת, כי אפליקציית צ’אט שמאחרת לענות אסור שתעכב את האתחול.

החברות מאשרת, לא החזקה באסימון

קטע בשם “החברות מאשרת, לא החזקה באסימון”

מי שהתקין את האפליקציה אינו מי ששולח את ההודעה. לכן כל אירוע נכנס מפוענח ל-Principal לפני שקורה משהו, והוא נכשל בסגירה עם הסבר:

  • חשבון צ’אט שאינו מקושר מקבל הסבר איך לקשר, בשרשור ה-Slack שבו כתב.
  • חשבון מקושר שהמשתמש שלו אינו חבר בסביבת העבודה הזו מתבקש לבקש הזמנה ממנהל.
  • חבר שתפקידו לקריאה בלבד (viewer, guest) נאמר לו שהתפקיד שלו אינו יכול להתחיל עבודה.

אף אחד מאלה אינו ממשיך. הודעת צ’אט לעולם אינה מיוחסת למי שלא שלח אותה — וזה מה ששומר על יומן הביקורת, על הגבלות הקצב לפי Principal ועל מדווחי הכרטיסים כאמינים. ראו רב־משתמשים — זהות, חברות ונוכחות עבור מודל החברות שהדבר נשען עליו.

קישור הוא פענוח זהות, לא הענקת גישה. החברות נבדקת מחדש בכל אירוע, כך שקישור של מי שהוסר מאז מפוענח כסירוב ולא מחזיר לו את הרשאותיו הקודמות. שני מסלולים יוצרים קישור: קוד חד־פעמי קצר־מועד שהחבר מקליד בצ’אט, או התאמה אוטומטית לפי האימייל ה_מאומת_ של הספק — ומסלול האימייל עדיין דורש שהמשתמש המותאם יהיה חבר מלכתחילה, כך שזר שכתובתו במקרה קיימת בשרת אינו מקושר לשום דבר.

הקודים נשמרים בכוונה בזיכרון, עם תוקף של 15 דקות. שמירתם בדיסק הייתה יוצרת רשומה קבועה בדמות פרטי גישה עבור משהו שנועד להתאדות, והפעלה מחדש של השרת שמבטלת כל קוד פתוח היא ההתנהגות הנכונה ולא רגרסיה — החבר פשוט לוחץ שוב על “קישור”.

סירוב נענה בשרשור ה-Slack שההודעה פתחה, כתשובה של ממש. תשובה בשרשור מצרפת תג להודעה שהיא עונה עליה, מגיעה לאדם היחיד שצריך אותה, ובכל זאת משאירה את “אינך מקושר” מחוץ לערוץ עצמו. פרסום הודעה הוא גם היכולת היחידה שיש לכל ספק, ולכן ההסבר לעולם אינו תלוי ביכולת שהאפליקציה אולי אינה כוללת.

גשר שמשקף לשני הכיוונים ייכנס ללולאה, אלא אם משהו מבחין בין שני הכיוונים. כל הודעה שהגשר מכניס נחתמת ב-metadata['chat'] — ספק, צוות, שיחה, שרשור, הודעה ושולח. השיקוף היוצא מדלג על כל הודעה שנושאת את החתימה הזו.

הכלל הבודד הזה הוא מה שהופך שיקוף דו־כיווני לבטוח: הודעה שאדם הקליד ב-Control Center מועברת החוצה אל השרשור המקושר (כששם הכותב מוצב לפניה), ואילו הודעה שהגיעה מהצ’אט נעצרת במקום שבו נכנסה.

מה שחוצה החוצה הוא צר בכוונה. התשובה של הסוכן מועברת כטקסט צ’אט רגיל. מחשבה קצרה יכולה להופיע בשורת ה-Thinking… של הכרטיס. פלט כלים הוא קיר של דיפים ונשאר ב-Control Center, וגם לשם מצביעה תשובה מקוצרת.

כרטיס מדווח על העבודה; הטקסט הוא עדיין ההודעה

קטע בשם “כרטיס מדווח על העבודה; הטקסט הוא עדיין ההודעה”

במקומות שהספק מציג אותם, תור נושא כרטיס משימה אחד, שנערך במקומו כל עוד התור רץ ויושב מעל התשובה שהוא מסביר.

הכרטיס הוא תוכנית אחת מקובצת, לא פסקה מרוסקת. Slack משרשר details ו-output לאותה משימה, ולכן ה-details של שורה נשלחים פעם אחת (המחשבה ב-Thinking…) ו-output לעולם אינו מוזרם. ההקמה היא שורה אחת שכותרתה מוחלפת (Working on it…, Cloning …). החשיבה מוסיפה שורת Thinking…. כל כלי הוא שורה משלו (Read lib/main.dart). התשובה היא טקסט רגיל מתחת לכרטיס — תצוגת התוכנית של Slack קוראת אותה מתוספת markdown מאוחרת, ולא מקטע שמעורבב לתוך המשימות. שום דבר אחר: בלי פלט כלים, ורק ארגומנט של כלי מתוך רשימת היתר קצרה, כי הארגומנטים של כלי לא מוכר יכולים להיות גוף קובץ שלם, ושורה היא שורה אחת.

הכרטיס מכסה גם את ההמתנה לפני התור. אזכור ראשון יוצר את השיחה, ויצירתה משכפלת כל מאגר שהסוכן יעבוד בו — ובמאגר אמיתי מדובר בדקות שבהן עוד לא ביקשו מהסוכן דבר והשרשור נראה כאילו הבוט התעלם מהבקשה. לכן הכרטיס נפתח ברגע שהאזכור מתקבל (Working on it…), ואז שורת ההקמה הזו מתעדכנת באותן מילים שאפליקציית שולחן העבודה מציגה — Cloning acme/widgets…, Setting up Reviewer…, Starting the agent… — ואחר כך שורת Thinking… ברגע שהסוכן מתחיל לחשוב, ואז שורה לכל כלי. כרטיס אחד, הודעה אחת, מ“שמעתי אותך” ועד לתשובה כטקסט רגיל מתחתיו. ההקמה מדווחת רק במקום שמישהו בצ’אט ממתין לה: שיחה שמכינים באפליקציה, או שיחה תקועה שמתחדשת באתחול השרת, עוברות הקמה בלי כרטיס כלל.

הוא נושא את הדבר היחיד שהצ’אט לא יכול לתת לקורא: קישור View in Control Center לשיחה שמאחורי התשובה. מוצרי צ’אט מקבלים http(s) בקישור ושום דבר אחר, ולכן הכפתור מצביע על עמוד קטן שהשרת הזה מגיש, והוא מפנה את הדפדפן לקישור control-center:// של אפליקציית שולחן העבודה. העמוד הזה אינו קורא דבר ואינו מפענח דבר — המזהים שבכתובת שלו מועתקים לקישור עמוק ולא לשום מקום אחר — ושרת בלי כתובת ציבורית נגישה פשוט מייצר כרטיס בלי כפתור, ולא כרטיס שבור.

כרטיסים הם יכולת כמו כל יכולת אחרת. ספק בלעדיהם מתנהג בדיוק כמו קודם, טקסט בלבד, וספק שמסרב לאחד בזמן הקריאה שומר על הזרם שלו: התשובה תמיד גוברת על הכרטיס שמתאר אותה. כשההזרמה כבויה לגמרי, הכרטיס המוגמר נוסע על ההודעה היחידה שפורסמה במקום. כרטיס שנפתח מקבל אותו יחס — אישור ה-/cc ticket הוא כרטיס מוגמר שכל סיבת קיומו היא הקישור לכרטיס שזה עתה נוצר.

היכולות מנוהלות במשא ומתן, ואז נזכרות

קטע בשם “היכולות מנוהלות במשא ומתן, ואז נזכרות”

מוצרי צ’אט נבדלים זה מזה במה שהם יודעים להציג, ולכן הגשר נכתב מול המשטח העשיר ביותר — תשובה שגדלה בזמן אמת בתוך שרשור, סטטוס חולף של “חושב…”, שרשור עם כותרת, תשובה חולפת לפקודת קו נטוי — ומדרדר יכולת אחת בכל פעם. ספק בלי שום דבר מזה עדיין עובד; התשובות פשוט מגיעות כהודעה אחת.

הצהרה על יכולת אינה הבטחה. התשובות המוזרמות של Slack דורשות תכונה של תוכנית בתשלום, ו-Slack מסרב רק בזמן הקריאה. סירוב שמסומן כקבוע נזכר לכל חיי החיבור: הגשר מפסיק לשאול ומפרסם תשובות שלמות מאותו רגע, וההגדרות אומרות זאת במפורש, כי על “התשובות שלי הפסיקו להיות מוזרמות” אפשר לענות בלי לקרוא לוג. כרטיסי משימה מושלכים באותה דרך אם Slack מסרב לאחד.

כל מגבלה שייכת לספק ולא לממסר: תקרת ההודעה, תקרת מקטעי הזרם והאם זרם צריך עוגן שרשור — כולם מוצהרים על ידי המתאם, ותשובה ארוכה מפוצלת בגבול של פסקה, שורה או מילה ולא באמצע מילה.

גשר אחד, מתאם אחד לכל מוצר

קטע בשם “גשר אחד, מתאם אחד לכל מוצר”

כל מה שדומה למוצר חי מאחורי יציאה. הליבה הגנרית מכירה markdown, שיחות צ’אט, שרשורי צ’אט וחברים; היא אינה יודעת דבר על מעטפות של Slack, על mrkdwn או על gateway intents. שלושה חלקים גורמים לזה לעבוד:

  • המתאם מנרמל מוצר אחד: את התעבורה שלו, את קריאות ה-API שלו, את מקודד הטקסט שלו ואת המוזרויות של המעטפות שלו. אלה של Slack מאלפים — אזכור בערוץ מגיע פעמיים (כ-app_mention וגם כ-message), ורוב תת־הסוגים של הודעות הם קישוט ולא בקשות חדשות. שניהם נפתרים במתאם, כך שהליבה לעולם אינה לומדת ש-Slack מספק פעמיים.
  • המתאר הוא הצורה הציבורית של הספק: אילו פרטי גישה הוא צריך, מה הפורמטים שלהם, אילו כתובות URL עוזרות למשתמש למצוא אותם, מה הוא יודע לעשות והאם אפשר ליצור או לערוך את האפליקציה שלו מכאן. כרטיס ההגדרות, דיאלוג החיבור ואימות הארגומנטים — כולם נוצרים ממנו; הלקוח שולח בחזרה ספק ומפה שממופתחת לפי מזהה שדה.
  • התוסף הוא ההרכבה בצד השרת: איך פרטי גישה הופכים לחיבור מאומת, איך נבנה מתאם ואיך מנוהלת האפליקציה בצד הספק.

אז הוספת מוצר היא ערך enum אחד, מתאם אחד ותוסף אחד. בלי פעולת RPC חדשה, בלי שינוי סכימה, בלי שינוי בלקוח — והלקוח נשאר אמין, כי שדה שמעולם לא שמע עליו עדיין מוצג עם התווית שהשרת סיפק.

שני פרטים מונעים מההפשטה לדלוף כלפי מעלה. שלבי ניהול האפליקציה שלספק אין API עבורם (יצירת אסימון, התקנת האפליקציה, התקנה מחדש אחרי שינוי הרשאות) מוחזרים כשלבים בעלי שם עם הקישורים שלהם, כך שמסך סיום ההקמה מציג אותם בלי לדעת איזה ספק זה. וכל הוראה שהגשר כותב נוקבת בשם הספק ובפקודה שהאפליקציה באמת משתמשת בה, כך שפקודה ששמה שונה או ספק שני נקראים נכון בלי מחרוזת חדשה.

היכן שוכנים פרטי הגישה

קטע בשם “היכן שוכנים פרטי הגישה”

אסימוני הצ’אט של סביבת עבודה יושבים בתיקייה שלה עצמה, לצד מסד הנתונים שלה — כ-JSON רגיל בנתיב <dataDir>/<workspaceId>/chat_credentials/<provider>.json, לא במסד הנתונים ובכוונה לא במחזיק המפתחות של מערכת ההפעלה. שתי השלכות קל לאבד אחר כך: מחיקת סביבת עבודה מסירה את התיקייה ואסימוניה הולכים איתה, וייצוא או גיבוי של סביבת עבודה מעתיק את workspace.db, כך שסביבת עבודה שנמסרת למישהו אחר אינה נושאת בתוכה אסימוני בוט חיים. הקובץ יורש את גבול האמון של מערכת הקבצים של מארח השרת — כל מי שיש לו גישת קריאה לתיקיית הנתונים יכול לקרוא אסימון בוט, וזה אותו גבול שמסדי הנתונים יושבים מאחוריו.

קובץ אחד לכל ספק, נכתב באופן אטומי ובהרשאת בעלים בלבד: אפליקציה שבוטלה נמחקת בהסרת קובץ אחד, וקובץ פגום עולה בספק אחד ולא בכולם. פרטי גישה נוסעים פנימה בפעולת החיבור ולעולם אינם יוצאים החוצה — קריאות סטטוס עונות במטא־נתונים של החיבור בלבד.

  • הגדרות אפליקציה מבניות. התעבורה, ההיקפים ומינויי האירועים נכפים ולא נחשפים. אפליקציה שאפשר להביא לדממה ממסך הגדרות היא פנייה לתמיכה, לא יכולת.
  • שיחה ופקודות קו נטוי. המשטח של Slack הוא הודעות, תשובות בשרשור ופקודות קו נטוי.
  • מקור אמת שני. הצ’אט אינו מקום שבו העבודה נשמרת. שרשור ה-Slack הוא מרחב, התשובה היא תמליל, הכרטיס הוא כרטיס — כולם בסביבת העבודה, נגישים מכל לקוח.